Refine AX naming and usage comments
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Virgil 2026-04-04 04:00:30 +00:00
parent 1f9624279a
commit e3986fb064
4 changed files with 115 additions and 121 deletions

View file

@ -15,16 +15,13 @@ import (
const signpostFilename = ".installed-miners"
// validateConfigPath validates that a config path is within the expected XDG config directory
// This prevents path traversal attacks via manipulated signpost files
// validateConfigPath("/home/alice/.config/lethean-desktop/miners/config.json") // nil
// validateConfigPath("/tmp/config.json") // error
func validateConfigPath(configPath string) error {
// Get the expected XDG config base directory
expectedBase := filepath.Join(xdg.ConfigHome, "lethean-desktop")
// Clean and resolve the config path
cleanPath := filepath.Clean(configPath)
// Check if the path is within the expected directory
if !strings.HasPrefix(cleanPath, expectedBase+string(os.PathSeparator)) && cleanPath != expectedBase {
return fmt.Errorf("invalid config path: must be within %s", expectedBase)
}
@ -45,7 +42,7 @@ var doctorCmd = &cobra.Command{
if err := updateDoctorCache(); err != nil {
return fmt.Errorf("failed to run doctor check: %w", err)
}
// After updating the cache, display the fresh results
// loadAndDisplayCache() // prints the refreshed miner summary after updateDoctorCache()
_, err := loadAndDisplayCache()
return err
},
@ -69,7 +66,7 @@ func loadAndDisplayCache() (bool, error) {
}
configPath := strings.TrimSpace(string(configPathBytes))
// Security: Validate that the config path is within the expected directory
// validateConfigPath("/home/alice/.config/lethean-desktop/miners/config.json") // blocks path traversal
if err := validateConfigPath(configPath); err != nil {
return false, fmt.Errorf("security error: %w", err)
}
@ -93,7 +90,7 @@ func loadAndDisplayCache() (bool, error) {
fmt.Println()
for _, details := range systemInfo.InstalledMinersInfo {
// Infer miner name from path for display purposes
// details.Path = "/home/alice/.local/share/lethean-desktop/miners/xmrig" -> "XMRig"
var minerName string
if details.Path != "" {
if strings.Contains(details.Path, "xmrig") {

View file

@ -13,20 +13,20 @@ import (
"forge.lthn.ai/Snider/Mining/pkg/logging"
)
// equalFold("xmrig", "XMRig") == true
// equalFold("tt-miner", "TT-Miner") == true
// equalFold("xmrig", "XMRig") // true
// equalFold("tt-miner", "TT-Miner") // true
func equalFold(left, right string) bool {
return bytes.EqualFold([]byte(left), []byte(right))
}
// hasPrefix("xmrig-rx0", "xmrig") == true
// hasPrefix("ttminer-rtx", "xmrig") == false
// hasPrefix("xmrig-rx0", "xmrig") // true
// hasPrefix("ttminer-rtx", "xmrig") // false
func hasPrefix(input, prefix string) bool {
return len(input) >= len(prefix) && input[:len(prefix)] == prefix
}
// containsStr("peer not found", "not found") == true
// containsStr("connection ok", "not found") == false
// containsStr("peer not found", "not found") // true
// containsStr("connection ok", "not found") // false
func containsStr(haystack, needle string) bool {
if len(needle) == 0 {
return true
@ -42,7 +42,7 @@ func containsStr(haystack, needle string) bool {
return false
}
// safe := instanceNameRegex.ReplaceAllString("my algo!", "_") // => "my_algo_"
// safeName := instanceNameRegex.ReplaceAllString("my algo!", "_") // "my_algo_"
var instanceNameRegex = regexp.MustCompile(`[^a-zA-Z0-9_/-]`)
// var managerInterface ManagerInterface = mining.NewManager()
@ -96,7 +96,7 @@ func (m *Manager) emitEvent(eventType EventType, data interface{}) {
var _ ManagerInterface = (*Manager)(nil)
// manager := mining.NewManager()
// defer manager.Stop()
// defer manager.Stop() // stops miner goroutines and the hourly database cleanup loop
func NewManager() *Manager {
manager := &Manager{
miners: make(map[string]Miner),
@ -111,7 +111,7 @@ func NewManager() *Manager {
}
// manager := mining.NewManagerForSimulation()
// manager.StartMiner(ctx, "xmrig", &mining.Config{Algo: "rx/0"})
// manager.StartMiner(ctx, "xmrig", &Config{Algo: "rx/0"})
func NewManagerForSimulation() *Manager {
manager := &Manager{
miners: make(map[string]Miner),
@ -122,7 +122,7 @@ func NewManagerForSimulation() *Manager {
return manager
}
// m.initDatabase() // called in NewManager(); enables persistence if MinersConfig.Database.Enabled == true
// m.initDatabase() // NewManager() calls this after loading miners.json, for example with Database.Enabled = true
func (m *Manager) initDatabase() {
minersConfiguration, err := LoadMinersConfig()
if err != nil {
@ -154,11 +154,11 @@ func (m *Manager) initDatabase() {
logging.Info("database persistence enabled", logging.Fields{"retention_days": m.databaseRetention})
// Start periodic cleanup
// m.startDBCleanup() // keeps database.Cleanup(30) running after persistence is enabled
m.startDBCleanup()
}
// m.startDBCleanup() // called after initDatabase(); purges rows older than m.databaseRetention days once per hour
// m.startDBCleanup() // runs database.Cleanup(30) once an hour after persistence is enabled
func (m *Manager) startDBCleanup() {
m.waitGroup.Add(1)
go func() {
@ -168,11 +168,11 @@ func (m *Manager) startDBCleanup() {
logging.Error("panic in database cleanup goroutine", logging.Fields{"panic": r})
}
}()
// Run cleanup once per hour
// ticker := time.NewTicker(time.Hour) // checks for expired rows every 60 minutes
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
// Run initial cleanup
// database.Cleanup(30) // removes rows older than 30 days during startup
if err := database.Cleanup(m.databaseRetention); err != nil {
logging.Warn("database cleanup failed", logging.Fields{"error": err})
}
@ -190,8 +190,7 @@ func (m *Manager) startDBCleanup() {
}()
}
// m.syncMinersConfig() // called on startup; adds {MinerType: "xmrig", Autostart: false} for any
// registered miner not yet present in miners.json — existing entries are left unchanged.
// m.syncMinersConfig() // when miners.json only contains tt-miner, this adds xmrig with Autostart=false
func (m *Manager) syncMinersConfig() {
minersConfiguration, err := LoadMinersConfig()
if err != nil {
@ -214,7 +213,7 @@ func (m *Manager) syncMinersConfig() {
minersConfiguration.Miners = append(minersConfiguration.Miners, MinerAutostartConfig{
MinerType: availableMiner.Name,
Autostart: false,
Config: nil, // No default config
Config: nil, // keep the new miner disabled until the user saves a profile
})
configUpdated = true
logging.Info("added default config for missing miner", logging.Fields{"miner": availableMiner.Name})
@ -228,7 +227,7 @@ func (m *Manager) syncMinersConfig() {
}
}
// m.autostartMiners() // called in NewManager(); reads miners.json and starts any entry with Autostart == true
// m.autostartMiners() // NewManager() uses this to start xmrig when miners.json contains Autostart=true
func (m *Manager) autostartMiners() {
minersConfiguration, err := LoadMinersConfig()
if err != nil {
@ -262,7 +261,7 @@ func findAvailablePort() (int, error) {
return listener.Addr().(*net.TCPAddr).Port, nil
}
// miner, err := manager.StartMiner(ctx, "xmrig", &mining.Config{Algo: "rx/0"})
// miner, err := manager.StartMiner(ctx, "xmrig", &Config{Algo: "rx/0"})
func (m *Manager) StartMiner(ctx context.Context, minerType string, config *Config) (Miner, error) {
// Check for cancellation before acquiring lock
select {
@ -324,13 +323,13 @@ func (m *Manager) StartMiner(ctx context.Context, minerType string, config *Conf
}
}
// Emit starting event before actually starting
// m.emitEvent(EventMinerStarting, MinerEventData{Name: "xmrig-rx_0"}) // fires before miner.Start(config)
m.emitEvent(EventMinerStarting, MinerEventData{
Name: instanceName,
})
if err := miner.Start(config); err != nil {
// Emit error event
// m.emitEvent(EventMinerError, MinerEventData{Name: "xmrig-rx_0", Error: err.Error()})
m.emitEvent(EventMinerError, MinerEventData{
Name: instanceName,
Error: err.Error(),
@ -414,7 +413,7 @@ func (m *Manager) UninstallMiner(ctx context.Context, minerType string) error {
})
}
// m.updateMinerConfig("xmrig", true, config) // persists autostart=true and last-used config to miners.json
// m.updateMinerConfig("xmrig", true, config) // saves Autostart=true and the last-used config back to miners.json
func (m *Manager) updateMinerConfig(minerType string, autostart bool, config *Config) error {
return UpdateMinersConfig(func(configuration *MinersConfig) error {
found := false
@ -498,7 +497,7 @@ func (m *Manager) StopMiner(ctx context.Context, name string) error {
return nil
}
// miner, err := m.GetMiner("xmrig-randomx")
// miner, err := m.GetMiner("xmrig-randomx") // returns ErrMinerNotFound when the name is missing
// if err != nil { /* miner not found */ }
func (m *Manager) GetMiner(name string) (Miner, error) {
m.mutex.RLock()
@ -522,8 +521,8 @@ func (m *Manager) ListMiners() []Miner {
return miners
}
// sim := NewSimulatedMiner(SimulatedMinerConfig{Name: "sim-rx0"})
// if err := manager.RegisterMiner(sim); err != nil { return err }
// simulatedMiner := NewSimulatedMiner(SimulatedMinerConfig{Name: "sim-rx0"})
// if err := manager.RegisterMiner(simulatedMiner); err != nil { return err }
func (m *Manager) RegisterMiner(miner Miner) error {
name := miner.GetName()
@ -545,7 +544,7 @@ func (m *Manager) RegisterMiner(miner Miner) error {
return nil
}
// for _, available := range manager.ListAvailableMiners() { logging.Info(available.Name, nil) }
// for _, availableMiner := range manager.ListAvailableMiners() { logging.Info(availableMiner.Name, nil) }
func (m *Manager) ListAvailableMiners() []AvailableMiner {
return []AvailableMiner{
{
@ -559,7 +558,7 @@ func (m *Manager) ListAvailableMiners() []AvailableMiner {
}
}
// m.startStatsCollection() // called in NewManager(); polls all active miners every HighResolutionInterval
// m.startStatsCollection() // NewManager() uses this to poll each running miner every HighResolutionInterval
func (m *Manager) startStatsCollection() {
m.waitGroup.Add(1)
go func() {
@ -586,7 +585,7 @@ func (m *Manager) startStatsCollection() {
// ctx, cancel := context.WithTimeout(ctx, statsCollectionTimeout)
const statsCollectionTimeout = 5 * time.Second
// m.collectMinerStats() // called by startStatsCollection ticker; gathers stats from all active miners in parallel
// m.collectMinerStats() // the stats ticker calls this to poll all running miners in parallel
func (m *Manager) collectMinerStats() {
// Take a snapshot of miners under read lock - minimize lock duration
m.mutex.RLock()
@ -610,11 +609,11 @@ func (m *Manager) collectMinerStats() {
now := time.Now()
// Collect stats from all miners in parallel
var waitGroup sync.WaitGroup
var collectionWaitGroup sync.WaitGroup
for _, entry := range miners {
waitGroup.Add(1)
collectionWaitGroup.Add(1)
go func(miner Miner, minerType string) {
defer waitGroup.Done()
defer collectionWaitGroup.Done()
defer func() {
if r := recover(); r != nil {
logging.Error("panic in single miner stats collection", logging.Fields{
@ -626,7 +625,7 @@ func (m *Manager) collectMinerStats() {
m.collectSingleMinerStats(miner, minerType, now, databaseEnabled)
}(entry.miner, entry.minerType)
}
waitGroup.Wait()
collectionWaitGroup.Wait()
}
// for attempt := 0; attempt <= statsRetryCount; attempt++ { ... }
@ -693,9 +692,9 @@ func (m *Manager) collectSingleMinerStats(miner Miner, minerType string, now tim
Timestamp: point.Timestamp,
Hashrate: point.Hashrate,
}
// Create a new context for DB writes (original context is from retry loop)
databaseContext, databaseCancel := context.WithTimeout(context.Background(), statsCollectionTimeout)
if err := database.InsertHashratePoint(databaseContext, minerName, minerType, databasePoint, database.ResolutionHigh); err != nil {
// database.InsertHashratePoint(ctx, "xmrig-rx_0", databasePoint, database.ResolutionHigh) // persists a single sample
databaseWriteContext, databaseCancel := context.WithTimeout(context.Background(), statsCollectionTimeout)
if err := database.InsertHashratePoint(databaseWriteContext, minerName, minerType, databasePoint, database.ResolutionHigh); err != nil {
logging.Warn("failed to persist hashrate", logging.Fields{"miner": minerName, "error": err})
}
databaseCancel()
@ -728,7 +727,7 @@ func (m *Manager) GetMinerHashrateHistory(name string) ([]HashratePoint, error)
// ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
const ShutdownTimeout = 10 * time.Second
// defer manager.Stop() // safe in main() or test cleanup; subsequent calls are no-ops
// defer manager.Stop() // stops miners, waits for goroutines, and closes the database during shutdown
func (m *Manager) Stop() {
m.stopOnce.Do(func() {
// Stop all running miners first

View file

@ -7,7 +7,7 @@ import (
"sync"
)
// var repo Repository[MinersConfig] = NewFileRepository[MinersConfig](path)
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json")
// data, err := repo.Load()
type Repository[T any] interface {
// data, err := repo.Load()
@ -20,7 +20,7 @@ type Repository[T any] interface {
Update(modifier func(*T) error) error
}
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig))
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig))
// data, err := repo.Load()
type FileRepository[T any] struct {
mutex sync.RWMutex
@ -28,17 +28,17 @@ type FileRepository[T any] struct {
defaults func() T
}
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig), myOption)
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig), myOption)
type FileRepositoryOption[T any] func(*FileRepository[T])
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig))
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig))
func WithDefaults[T any](defaultsProvider func() T) FileRepositoryOption[T] {
return func(repo *FileRepository[T]) {
repo.defaults = defaultsProvider
}
}
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig))
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig))
func NewFileRepository[T any](path string, options ...FileRepositoryOption[T]) *FileRepository[T] {
repo := &FileRepository[T]{
path: path,
@ -83,7 +83,7 @@ func (repository *FileRepository[T]) Save(data T) error {
return repository.saveUnlocked(data)
}
// return repository.saveUnlocked(data) // called by Save and Update while mutex is held
// repository.saveUnlocked(updatedConfig) // used by Save() and Update() while the mutex is already held
func (repository *FileRepository[T]) saveUnlocked(data T) error {
dir := filepath.Dir(repository.path)
if err := os.MkdirAll(dir, 0755); err != nil {
@ -132,7 +132,7 @@ func (repository *FileRepository[T]) Update(modifier func(*T) error) error {
return repository.saveUnlocked(data)
}
// path := repo.Path() // => "/home/user/.config/lethean-desktop/miners.json"
// path := repo.Path() // path == "/home/alice/.config/lethean-desktop/miners.json"
func (repository *FileRepository[T]) Path() string {
return repository.path
}

View file

@ -133,7 +133,7 @@ func isRetryableError(status int) bool {
status == http.StatusGatewayTimeout
}
// router.Use(securityHeadersMiddleware()) // X-Content-Type-Options, X-Frame-Options, CSP on every response
// router.Use(securityHeadersMiddleware()) // sets X-Content-Type-Options=nosniff and CSP=default-src 'none' on every response
func securityHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent MIME type sniffing
@ -288,7 +288,7 @@ const (
CachePublic5Min = "public, max-age=300"
)
// router.Use(cacheMiddleware()) // sets Cache-Control: no-store on mutating routes, max-age=30 on read-only routes
// router.Use(cacheMiddleware()) // serves /miners/available with Cache-Control: public, max-age=300
func cacheMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Only cache GET requests
@ -320,7 +320,7 @@ func cacheMiddleware() gin.HandlerFunc {
}
}
// router.Use(requestTimeoutMiddleware(30 * time.Second))
// router.Use(requestTimeoutMiddleware(30 * time.Second)) // aborts a slow /history/miners/xmrig request after 30 seconds
func requestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip timeout for WebSocket upgrades and streaming endpoints
@ -407,29 +407,29 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
}
}
// Initialize node service (optional - only fails if XDG paths are broken)
// NewNodeService() // falls back to miner-only API mode when XDG paths are unavailable
nodeService, err := NewNodeService()
if err != nil {
logging.Warn("failed to initialize node service", logging.Fields{"error": err})
// Continue without node service - P2P features will be unavailable
}
// Initialize event hub for WebSocket real-time updates
// NewEventHub() // broadcasts miner events to /ws/events clients
eventHub := NewEventHub()
go eventHub.Run()
// Wire up event hub to manager for miner events
// concreteManager.SetEventHub(eventHub) // lets Manager broadcast start/stop/stat events
if concreteManager, ok := manager.(*Manager); ok {
concreteManager.SetEventHub(eventHub)
}
// Set up state provider for WebSocket state sync on reconnect
// eventHub.SetStateProvider(...) // returns running miner state after a reconnect
eventHub.SetStateProvider(func() interface{} {
miners := manager.ListMiners()
if len(miners) == 0 {
return nil
}
// Return current state of all miners
// state := []map[string]interface{}{{"name": "xmrig-rx_0", "status": "running"}}
state := make([]map[string]interface{}, 0, len(miners))
for _, miner := range miners {
stats, _ := miner.GetStats(context.Background())
@ -450,7 +450,7 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
}
})
// Initialize authentication from environment
// AuthConfigFromEnv() // enables digest auth when MINING_API_USERNAME and MINING_API_PASSWORD are set
authConfig := AuthConfigFromEnv()
var auth *DigestAuth
if authConfig.Enabled {
@ -479,11 +479,11 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
}
// service.InitRouter()
// http.Handle("/", service.Router) // embed as handler in Wails or another HTTP server
// http.Handle("/", service.Router) // embeds the API under a parent HTTP server in Wails
func (s *Service) InitRouter() {
s.Router = gin.Default()
// Extract port safely from server address for CORS
// s.Server.Addr = ":9090" -> serverPort = "9090" for local CORS origins
serverPort := "9090" // default fallback
if s.Server.Addr != "" {
if _, port, err := net.SplitHostPort(s.Server.Addr); err == nil && port != "" {
@ -510,39 +510,38 @@ func (s *Service) InitRouter() {
}
s.Router.Use(cors.New(corsConfig))
// Add security headers (SEC-LOW-4)
// s.Router.Use(securityHeadersMiddleware()) // sets security headers on every API response
s.Router.Use(securityHeadersMiddleware())
// Add Content-Type validation for POST/PUT (API-MED-8)
// s.Router.Use(contentTypeValidationMiddleware()) // rejects POST /miners/xmrig/install without application/json
s.Router.Use(contentTypeValidationMiddleware())
// Add request body size limit middleware (1MB max)
// c.Request.Body = http.MaxBytesReader(..., 1<<20) // caps request bodies at 1 MiB
s.Router.Use(func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) // 1MB
c.Next()
})
// Add CSRF protection for browser requests (SEC-MED-3)
// Requires X-Requested-With or Authorization header for state-changing methods
// s.Router.Use(csrfMiddleware()) // allows API clients with Authorization or X-Requested-With
s.Router.Use(csrfMiddleware())
// Add request timeout middleware (RESIL-MED-8)
// s.Router.Use(requestTimeoutMiddleware(DefaultRequestTimeout)) // aborts stalled requests after 30 seconds
s.Router.Use(requestTimeoutMiddleware(DefaultRequestTimeout))
// Add cache headers middleware (API-MED-7)
// s.Router.Use(cacheMiddleware()) // returns Cache-Control: public, max-age=300 for /miners/available
s.Router.Use(cacheMiddleware())
// Add X-Request-ID middleware for request tracing
// s.Router.Use(requestIDMiddleware()) // preserves the incoming X-Request-ID or creates a new one
s.Router.Use(requestIDMiddleware())
// Add rate limiting (10 requests/second with burst of 20)
// NewRateLimiter(10, 20) // allows bursts of 20 with a 10 requests/second refill rate
s.rateLimiter = NewRateLimiter(10, 20)
s.Router.Use(s.rateLimiter.Middleware())
s.SetupRoutes()
}
// service.Stop() // called by ServiceStartup on ctx.Done(); also safe to call directly for embedded use
// service.Stop() // stops rate limiting, auth, event hub, and node transport during shutdown
func (s *Service) Stop() {
if s.rateLimiter != nil {
s.rateLimiter.Stop()
@ -560,29 +559,29 @@ func (s *Service) Stop() {
}
}
// service.ServiceStartup(ctx) // blocks until ctx is cancelled; use InitRouter() to embed without a server
// service.ServiceStartup(ctx) // starts the HTTP server and stops it when ctx.Done() fires
func (s *Service) ServiceStartup(ctx context.Context) error {
s.InitRouter()
s.Server.Handler = s.Router
// Channel to capture server startup errors
errChan := make(chan error, 1)
// serverErrorChannel captures ListenAndServe failures without blocking startup
serverErrorChannel := make(chan error, 1)
go func() {
if err := s.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logging.Error("server error", logging.Fields{"addr": s.Server.Addr, "error": err})
errChan <- err
serverErrorChannel <- err
}
close(errChan) // Prevent goroutine leak
close(serverErrorChannel) // prevent goroutine leak
}()
go func() {
<-ctx.Done()
s.Stop() // Clean up service resources (auth, event hub, node service)
s.Manager.Stop()
ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Server.Shutdown(ctxShutdown); err != nil {
if err := s.Server.Shutdown(shutdownContext); err != nil {
logging.Error("server shutdown error", logging.Fields{"error": err})
}
}()
@ -591,7 +590,7 @@ func (s *Service) ServiceStartup(ctx context.Context) error {
maxRetries := 50 // 50 * 100ms = 5 seconds max
for i := 0; i < maxRetries; i++ {
select {
case err := <-errChan:
case err := <-serverErrorChannel:
if err != nil {
return ErrInternal("failed to start server").WithCause(err)
}
@ -613,64 +612,64 @@ func (s *Service) ServiceStartup(ctx context.Context) error {
// service.InitRouter()
// service.SetupRoutes() // re-call after adding middleware manually
func (s *Service) SetupRoutes() {
apiGroup := s.Router.Group(s.APIBasePath)
apiRouterGroup := s.Router.Group(s.APIBasePath)
// Health endpoints (no auth required for orchestration/monitoring)
apiGroup.GET("/health", s.handleHealth)
apiGroup.GET("/ready", s.handleReady)
apiRouterGroup.GET("/health", s.handleHealth)
apiRouterGroup.GET("/ready", s.handleReady)
// Apply authentication middleware if enabled
if s.auth != nil {
apiGroup.Use(s.auth.Middleware())
apiRouterGroup.Use(s.auth.Middleware())
}
{
apiGroup.GET("/info", s.handleGetInfo)
apiGroup.GET("/metrics", s.handleMetrics)
apiGroup.POST("/doctor", s.handleDoctor)
apiGroup.POST("/update", s.handleUpdateCheck)
apiRouterGroup.GET("/info", s.handleGetInfo)
apiRouterGroup.GET("/metrics", s.handleMetrics)
apiRouterGroup.POST("/doctor", s.handleDoctor)
apiRouterGroup.POST("/update", s.handleUpdateCheck)
minersGroup := apiGroup.Group("/miners")
minersRouterGroup := apiRouterGroup.Group("/miners")
{
minersGroup.GET("", s.handleListMiners)
minersGroup.GET("/available", s.handleListAvailableMiners)
minersGroup.POST("/:miner_name/install", s.handleInstallMiner)
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)
minersGroup.GET("/:miner_name/logs", s.handleGetMinerLogs)
minersGroup.POST("/:miner_name/stdin", s.handleMinerStdin)
minersRouterGroup.GET("", s.handleListMiners)
minersRouterGroup.GET("/available", s.handleListAvailableMiners)
minersRouterGroup.POST("/:miner_name/install", s.handleInstallMiner)
minersRouterGroup.DELETE("/:miner_name/uninstall", s.handleUninstallMiner)
minersRouterGroup.DELETE("/:miner_name", s.handleStopMiner)
minersRouterGroup.GET("/:miner_name/stats", s.handleGetMinerStats)
minersRouterGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory)
minersRouterGroup.GET("/:miner_name/logs", s.handleGetMinerLogs)
minersRouterGroup.POST("/:miner_name/stdin", s.handleMinerStdin)
}
// Historical data endpoints (database-backed)
historyGroup := apiGroup.Group("/history")
historyRouterGroup := apiRouterGroup.Group("/history")
{
historyGroup.GET("/status", s.handleHistoryStatus)
historyGroup.GET("/miners", s.handleAllMinersHistoricalStats)
historyGroup.GET("/miners/:miner_name", s.handleMinerHistoricalStats)
historyGroup.GET("/miners/:miner_name/hashrate", s.handleMinerHistoricalHashrate)
historyRouterGroup.GET("/status", s.handleHistoryStatus)
historyRouterGroup.GET("/miners", s.handleAllMinersHistoricalStats)
historyRouterGroup.GET("/miners/:miner_name", s.handleMinerHistoricalStats)
historyRouterGroup.GET("/miners/:miner_name/hashrate", s.handleMinerHistoricalHashrate)
}
profilesGroup := apiGroup.Group("/profiles")
profilesRouterGroup := apiRouterGroup.Group("/profiles")
{
profilesGroup.GET("", s.handleListProfiles)
profilesGroup.POST("", s.handleCreateProfile)
profilesGroup.GET("/:id", s.handleGetProfile)
profilesGroup.PUT("/:id", s.handleUpdateProfile)
profilesGroup.DELETE("/:id", s.handleDeleteProfile)
profilesGroup.POST("/:id/start", s.handleStartMinerWithProfile)
profilesRouterGroup.GET("", s.handleListProfiles)
profilesRouterGroup.POST("", s.handleCreateProfile)
profilesRouterGroup.GET("/:id", s.handleGetProfile)
profilesRouterGroup.PUT("/:id", s.handleUpdateProfile)
profilesRouterGroup.DELETE("/:id", s.handleDeleteProfile)
profilesRouterGroup.POST("/:id/start", s.handleStartMinerWithProfile)
}
// WebSocket endpoint for real-time events
wsGroup := apiGroup.Group("/ws")
websocketRouterGroup := apiRouterGroup.Group("/ws")
{
wsGroup.GET("/events", s.handleWebSocketEvents)
websocketRouterGroup.GET("/events", s.handleWebSocketEvents)
}
// Add P2P node endpoints if node service is available
if s.NodeService != nil {
s.NodeService.SetupRoutes(apiGroup)
s.NodeService.SetupRoutes(apiRouterGroup)
}
}
@ -683,8 +682,7 @@ func (s *Service) SetupRoutes() {
swaggerURL := ginSwagger.URL("http://" + s.DisplayAddr + s.SwaggerUIPath + "/doc.json")
s.Router.GET(s.SwaggerUIPath+"/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, swaggerURL, ginSwagger.InstanceName(s.SwaggerInstanceName)))
// Initialize MCP server for AI assistant integration
// This exposes API endpoints as MCP tools for Claude, Cursor, etc.
// ginmcp.New(s.Router, ...) // exposes the API as MCP tools for Claude or Cursor
s.mcpServer = ginmcp.New(s.Router, &ginmcp.Config{
Name: "Mining API",
Description: "Mining dashboard API exposed via Model Context Protocol (MCP)",
@ -725,7 +723,7 @@ func (s *Service) handleReady(c *gin.Context) {
components := make(map[string]string)
allReady := true
// Check manager
// s.Manager != nil -> "manager": "ready"
if s.Manager != nil {
components["manager"] = "ready"
} else {
@ -733,15 +731,15 @@ func (s *Service) handleReady(c *gin.Context) {
allReady = false
}
// Check profile manager
// s.ProfileManager != nil -> "profiles": "ready"
if s.ProfileManager != nil {
components["profiles"] = "ready"
} else {
components["profiles"] = "degraded"
// Don't fail readiness for degraded profile manager
// keep readiness green when only profile loading is degraded
}
// Check event hub
// s.EventHub != nil -> "events": "ready"
if s.EventHub != nil {
components["events"] = "ready"
} else {
@ -749,7 +747,7 @@ func (s *Service) handleReady(c *gin.Context) {
allReady = false
}
// Check node service (optional)
// s.NodeService != nil -> "p2p": "ready"
if s.NodeService != nil {
components["p2p"] = "ready"
} else {
@ -796,7 +794,7 @@ func (s *Service) updateInstallationCache() (*SystemInfo, error) {
Architecture: runtime.GOARCH,
GoVersion: runtime.Version(),
AvailableCPUCores: runtime.NumCPU(),
InstalledMinersInfo: []*InstallationDetails{}, // Initialize as empty slice
InstalledMinersInfo: []*InstallationDetails{}, // keep /info responses stable when no miners are installed
}
vMem, err := mem.VirtualMemory()