diff --git a/cmd/mining/cmd/root.go b/cmd/mining/cmd/root.go index 17fd39a..e6b221c 100644 --- a/cmd/mining/cmd/root.go +++ b/cmd/mining/cmd/root.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "github.com/Snider/Mining/pkg/mining" "github.com/spf13/cobra" ) @@ -30,6 +32,10 @@ func init() { // initManager initializes the miner manager func initManager() { + // Skip for commands that create their own manager (like simulate) + if len(os.Args) > 1 && os.Args[1] == "simulate" { + return + } if manager == nil { manager = mining.NewManager() } diff --git a/cmd/mining/cmd/simulate.go b/cmd/mining/cmd/simulate.go new file mode 100644 index 0000000..3c1d9b5 --- /dev/null +++ b/cmd/mining/cmd/simulate.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "context" + "fmt" + "math/rand" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/Snider/Mining/pkg/mining" + "github.com/spf13/cobra" +) + +var ( + simCount int + simPreset string + simHashrate int + simAlgorithm string +) + +// simulateCmd represents the simulate command +var simulateCmd = &cobra.Command{ + Use: "simulate", + Short: "Start the service with simulated miners for UI testing", + Long: `Start the mining service with simulated miners that generate realistic +hashrate data and statistics. This is useful for UI development and testing +without requiring actual mining hardware. + +Examples: + # Start with 3 medium-hashrate CPU miners + miner-ctrl simulate --count 3 --preset cpu-medium + + # Start with custom hashrate + miner-ctrl simulate --count 2 --hashrate 8000 --algorithm rx/0 + + # Start with a mix of presets + miner-ctrl simulate --count 1 --preset gpu-ethash + +Available presets: + cpu-low - Low-end CPU (500 H/s, rx/0) + cpu-medium - Medium CPU (5 kH/s, rx/0) + cpu-high - High-end CPU (15 kH/s, rx/0) + gpu-ethash - GPU mining ETH (30 MH/s, ethash) + gpu-kawpow - GPU mining RVN (15 MH/s, kawpow)`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + displayHost := host + if displayHost == "0.0.0.0" { + var err error + displayHost, err = getLocalIP() + if err != nil { + displayHost = "localhost" + } + } + displayAddr := fmt.Sprintf("%s:%d", displayHost, port) + listenAddr := fmt.Sprintf("%s:%d", host, port) + + // Create a new manager for simulation (skips autostart of real miners) + mgr := mining.NewManagerForSimulation() + + // Create and start simulated miners + for i := 0; i < simCount; i++ { + config := getSimulatedConfig(i) + simMiner := mining.NewSimulatedMiner(config) + + // Start the simulated miner + if err := simMiner.Start(&mining.Config{}); err != nil { + return fmt.Errorf("failed to start simulated miner %d: %w", i, err) + } + + // Register with manager + if err := mgr.RegisterMiner(simMiner); err != nil { + return fmt.Errorf("failed to register simulated miner %d: %w", i, err) + } + + fmt.Printf("Started simulated miner: %s (%s, ~%d H/s)\n", + config.Name, config.Algorithm, config.BaseHashrate) + } + + // Create and start the service + service, err := mining.NewService(mgr, listenAddr, displayAddr, namespace) + if err != nil { + return fmt.Errorf("failed to create new service: %w", err) + } + + // Start the server in a goroutine + go func() { + if err := service.ServiceStartup(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Failed to start service: %v\n", err) + cancel() + } + }() + + fmt.Printf("\n=== SIMULATION MODE ===\n") + fmt.Printf("Mining service started on http://%s:%d\n", displayHost, port) + fmt.Printf("Swagger documentation is available at http://%s:%d%s/swagger/index.html\n", displayHost, port, namespace) + fmt.Printf("\nSimulating %d miner(s). Press Ctrl+C to stop.\n", simCount) + fmt.Printf("Note: All data is simulated - no actual mining is occurring.\n\n") + + // Handle graceful shutdown on Ctrl+C + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-signalChan: + fmt.Println("\nReceived shutdown signal, stopping simulation...") + cancel() + case <-ctx.Done(): + } + + // Stop all simulated miners + for _, miner := range mgr.ListMiners() { + mgr.StopMiner(miner.GetName()) + } + + fmt.Println("Simulation stopped.") + return nil + }, +} + +// getSimulatedConfig returns configuration for a simulated miner based on flags. +func getSimulatedConfig(index int) mining.SimulatedMinerConfig { + // Generate unique name + name := fmt.Sprintf("sim-%s-%03d", simPreset, index+1) + + // Start with preset if specified + var config mining.SimulatedMinerConfig + if preset, ok := mining.SimulatedMinerPresets[simPreset]; ok { + config = preset + } else { + // Default preset + config = mining.SimulatedMinerPresets["cpu-medium"] + } + + config.Name = name + + // Override with custom values if provided + if simHashrate > 0 { + config.BaseHashrate = simHashrate + } + if simAlgorithm != "" { + config.Algorithm = simAlgorithm + } + + // Add some variance between miners + variance := 0.1 + rand.Float64()*0.1 // 10-20% variance + config.BaseHashrate = int(float64(config.BaseHashrate) * (0.9 + rand.Float64()*0.2)) + config.Variance = variance + + return config +} + +func init() { + // Seed random for varied simulation + rand.Seed(time.Now().UnixNano()) + + simulateCmd.Flags().IntVarP(&simCount, "count", "c", 1, "Number of simulated miners to create") + simulateCmd.Flags().StringVar(&simPreset, "preset", "cpu-medium", "Miner preset (cpu-low, cpu-medium, cpu-high, gpu-ethash, gpu-kawpow)") + simulateCmd.Flags().IntVar(&simHashrate, "hashrate", 0, "Custom base hashrate (overrides preset)") + simulateCmd.Flags().StringVar(&simAlgorithm, "algorithm", "", "Custom algorithm (overrides preset)") + + // Reuse serve command flags + simulateCmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host to listen on") + simulateCmd.Flags().IntVarP(&port, "port", "p", 9090, "Port to listen on") + simulateCmd.Flags().StringVarP(&namespace, "namespace", "n", "/api/v1/mining", "API namespace") + + rootCmd.AddCommand(simulateCmd) +} + +// Helper function to format hashrate +func formatHashrate(h int) string { + if h >= 1000000000 { + return strconv.FormatFloat(float64(h)/1000000000, 'f', 2, 64) + " GH/s" + } + if h >= 1000000 { + return strconv.FormatFloat(float64(h)/1000000, 'f', 2, 64) + " MH/s" + } + if h >= 1000 { + return strconv.FormatFloat(float64(h)/1000, 'f', 2, 64) + " kH/s" + } + return strconv.Itoa(h) + " H/s" +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ad5477..154a7a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,3 +62,57 @@ The `Service` struct (`pkg/mining/service.go`) wraps the `Manager` and exposes i 3. **Manager Layer**: The manager looks up the appropriate `Miner` implementation. 4. **Miner Layer**: The miner instance interacts with the OS (filesystem, processes). 5. **Feedback**: Status and stats are returned up the stack to the user. + +## Real-Time Communication + +### WebSocket Events + +The system uses WebSocket for real-time event delivery to the UI (`pkg/mining/events.go`): + +``` +Angular UI <──WebSocket──> Go EventHub <── Manager (stats/events) + <── Miner processes +``` + +**Event Types:** +- `miner.starting` / `miner.started` - Miner lifecycle +- `miner.stopping` / `miner.stopped` - Miner shutdown +- `miner.stats` - Periodic hashrate/share updates +- `miner.error` - Connection or pool errors +- `profile.*` - Profile CRUD events + +The `EventHub` manages client connections with automatic cleanup on disconnect. + +### Angular WebSocket Service + +The frontend (`ui/src/app/websocket.service.ts`) maintains a persistent WebSocket connection with: +- Automatic reconnection with exponential backoff +- Event filtering by type +- Fallback to HTTP polling if WebSocket unavailable + +## Simulation Mode + +For development without mining hardware, the `SimulatedMiner` (`pkg/mining/simulated_miner.go`) provides: +- Realistic hashrate generation with variance and sine-wave fluctuation +- 30-second ramp-up period +- Simulated share acceptance (98% success rate) +- XMRig-compatible stats format for UI compatibility + +Usage: `miner-ctrl simulate --count 3 --preset cpu-high` + +## Supported Miners + +The system is designed to support multiple mining software through a plugin architecture: + +| Miner | Status | Type | API | +|-------|--------|------|-----| +| XMRig | Implemented | CPU/GPU | HTTP REST | +| TT-Miner | Implemented | NVIDIA GPU | HTTP | +| T-Rex | Planned | NVIDIA GPU | HTTP REST | +| lolMiner | Planned | AMD/NVIDIA/Intel | HTTP JSON | +| Rigel | Planned | NVIDIA GPU | HTTP REST | +| BzMiner | Planned | AMD/NVIDIA | HTTP | +| SRBMiner | Planned | CPU+GPU | HTTP | +| TeamRedMiner | Planned | AMD GPU | Claymore API | +| GMiner | Planned | GPU | HTTP | +| NBMiner | Planned | GPU | HTTP REST | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 99ecfe1..706b840 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -12,6 +12,22 @@ This guide is for developers contributing to the Mining project. The project uses a `Makefile` to automate common tasks. +### Simulation Mode + +For UI development without real mining hardware, use the simulation mode: + +```bash +# Start with 3 simulated CPU miners +miner-ctrl simulate --count 3 --preset cpu-high + +# Custom hashrate and algorithm +miner-ctrl simulate --count 2 --hashrate 8000 --algorithm rx/0 + +# Available presets: cpu-low, cpu-medium, cpu-high, gpu-ethash, gpu-kawpow +``` + +This generates realistic hashrate data with variance, share events, and pool connections for testing the UI. + ### Building Build the CLI binary for the current platform: diff --git a/pkg/mining/events.go b/pkg/mining/events.go new file mode 100644 index 0000000..86e2cbd --- /dev/null +++ b/pkg/mining/events.go @@ -0,0 +1,331 @@ +package mining + +import ( + "encoding/json" + "log" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// EventType represents the type of mining event +type EventType string + +const ( + // Miner lifecycle events + EventMinerStarting EventType = "miner.starting" + EventMinerStarted EventType = "miner.started" + EventMinerStopping EventType = "miner.stopping" + EventMinerStopped EventType = "miner.stopped" + EventMinerStats EventType = "miner.stats" + EventMinerError EventType = "miner.error" + EventMinerConnected EventType = "miner.connected" + + // Profile events + EventProfileCreated EventType = "profile.created" + EventProfileUpdated EventType = "profile.updated" + EventProfileDeleted EventType = "profile.deleted" + + // System events + EventPong EventType = "pong" +) + +// Event represents a mining event that can be broadcast to clients +type Event struct { + Type EventType `json:"type"` + Timestamp time.Time `json:"timestamp"` + Data interface{} `json:"data,omitempty"` +} + +// MinerStatsData contains stats data for a miner event +type MinerStatsData struct { + Name string `json:"name"` + Hashrate int `json:"hashrate"` + Shares int `json:"shares"` + Rejected int `json:"rejected"` + Uptime int `json:"uptime"` + Algorithm string `json:"algorithm,omitempty"` + DiffCurrent int `json:"diffCurrent,omitempty"` +} + +// MinerEventData contains basic miner event data +type MinerEventData struct { + Name string `json:"name"` + ProfileID string `json:"profileId,omitempty"` + Reason string `json:"reason,omitempty"` + Error string `json:"error,omitempty"` + Pool string `json:"pool,omitempty"` +} + +// wsClient represents a WebSocket client connection +type wsClient struct { + conn *websocket.Conn + send chan []byte + hub *EventHub + miners map[string]bool // subscribed miners, "*" for all + closeOnce sync.Once +} + +// EventHub manages WebSocket connections and event broadcasting +type EventHub struct { + // Registered clients + clients map[*wsClient]bool + + // Inbound events to broadcast + broadcast chan Event + + // Register requests from clients + register chan *wsClient + + // Unregister requests from clients + unregister chan *wsClient + + // Mutex for thread-safe access + mu sync.RWMutex + + // Stop signal + stop chan struct{} +} + +// NewEventHub creates a new EventHub +func NewEventHub() *EventHub { + return &EventHub{ + clients: make(map[*wsClient]bool), + broadcast: make(chan Event, 256), + register: make(chan *wsClient), + unregister: make(chan *wsClient), + stop: make(chan struct{}), + } +} + +// Run starts the EventHub's main loop +func (h *EventHub) Run() { + for { + select { + case <-h.stop: + // Close all client connections + h.mu.Lock() + for client := range h.clients { + close(client.send) + delete(h.clients, client) + } + h.mu.Unlock() + return + + case client := <-h.register: + h.mu.Lock() + h.clients[client] = true + h.mu.Unlock() + log.Printf("[EventHub] Client connected (total: %d)", len(h.clients)) + + case client := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + log.Printf("[EventHub] Client disconnected (total: %d)", len(h.clients)) + + case event := <-h.broadcast: + data, err := json.Marshal(event) + if err != nil { + log.Printf("[EventHub] Failed to marshal event: %v", err) + continue + } + + h.mu.RLock() + for client := range h.clients { + // Check if client is subscribed to this miner + if h.shouldSendToClient(client, event) { + select { + case client.send <- data: + default: + // Client buffer full, close connection + go func(c *wsClient) { + h.unregister <- c + }(client) + } + } + } + h.mu.RUnlock() + } + } +} + +// shouldSendToClient checks if an event should be sent to a client +func (h *EventHub) shouldSendToClient(client *wsClient, event Event) bool { + // Always send pong and system events + if event.Type == EventPong { + return true + } + + // Check miner subscription for miner events + if client.miners == nil || len(client.miners) == 0 { + // No subscription filter, send all + return true + } + + // Check for wildcard subscription + if client.miners["*"] { + return true + } + + // Extract miner name from event data + minerName := "" + switch data := event.Data.(type) { + case MinerStatsData: + minerName = data.Name + case MinerEventData: + minerName = data.Name + case map[string]interface{}: + if name, ok := data["name"].(string); ok { + minerName = name + } + } + + if minerName == "" { + // Non-miner event, send to all + return true + } + + return client.miners[minerName] +} + +// Stop stops the EventHub +func (h *EventHub) Stop() { + close(h.stop) +} + +// Broadcast sends an event to all subscribed clients +func (h *EventHub) Broadcast(event Event) { + if event.Timestamp.IsZero() { + event.Timestamp = time.Now() + } + select { + case h.broadcast <- event: + default: + log.Printf("[EventHub] Broadcast channel full, dropping event: %s", event.Type) + } +} + +// ClientCount returns the number of connected clients +func (h *EventHub) ClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// NewEvent creates a new event with the current timestamp +func NewEvent(eventType EventType, data interface{}) Event { + return Event{ + Type: eventType, + Timestamp: time.Now(), + Data: data, + } +} + +// writePump pumps messages from the hub to the websocket connection +func (c *wsClient) writePump() { + ticker := time.NewTicker(30 * time.Second) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + // Hub closed the channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + + if err := w.Close(); err != nil { + return + } + + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// readPump pumps messages from the websocket connection to the hub +func (c *wsClient) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(512) + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + for { + _, message, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("[EventHub] WebSocket error: %v", err) + } + break + } + + // Parse client message + var msg struct { + Type string `json:"type"` + Miners []string `json:"miners,omitempty"` + } + if err := json.Unmarshal(message, &msg); err != nil { + continue + } + + switch msg.Type { + case "subscribe": + // Update miner subscription + c.miners = make(map[string]bool) + for _, m := range msg.Miners { + c.miners[m] = true + } + log.Printf("[EventHub] Client subscribed to miners: %v", msg.Miners) + + case "ping": + // Respond with pong + c.hub.Broadcast(Event{ + Type: EventPong, + Timestamp: time.Now(), + }) + } + } +} + +// ServeWs handles websocket requests from clients +func (h *EventHub) ServeWs(conn *websocket.Conn) { + client := &wsClient{ + conn: conn, + send: make(chan []byte, 256), + hub: h, + miners: map[string]bool{"*": true}, // Subscribe to all by default + } + + h.register <- client + + // Start read/write pumps + go client.writePump() + go client.readPump() +} diff --git a/pkg/mining/manager.go b/pkg/mining/manager.go index 4e5dbd9..8f1764e 100644 --- a/pkg/mining/manager.go +++ b/pkg/mining/manager.go @@ -38,6 +38,27 @@ type Manager struct { waitGroup sync.WaitGroup dbEnabled bool dbRetention int + eventHub *EventHub + eventHubMu sync.RWMutex // Separate mutex for eventHub to avoid deadlock with main mu +} + +// SetEventHub sets the event hub for broadcasting miner events +func (m *Manager) SetEventHub(hub *EventHub) { + m.eventHubMu.Lock() + defer m.eventHubMu.Unlock() + m.eventHub = hub +} + +// emitEvent broadcasts an event if an event hub is configured +// Uses separate eventHubMu to avoid deadlock when called while holding m.mu +func (m *Manager) emitEvent(eventType EventType, data interface{}) { + m.eventHubMu.RLock() + hub := m.eventHub + m.eventHubMu.RUnlock() + + if hub != nil { + hub.Broadcast(NewEvent(eventType, data)) + } } var _ ManagerInterface = (*Manager)(nil) @@ -56,6 +77,19 @@ func NewManager() *Manager { return m } +// NewManagerForSimulation creates a manager for simulation mode. +// It skips autostarting real miners and config sync, suitable for UI testing. +func NewManagerForSimulation() *Manager { + m := &Manager{ + miners: make(map[string]Miner), + stopChan: make(chan struct{}), + waitGroup: sync.WaitGroup{}, + } + // Skip syncMinersConfig and autostartMiners for simulation + m.startStatsCollection() + return m +} + // initDatabase initializes the SQLite database based on config. func (m *Manager) initDatabase() { cfg, err := LoadMinersConfig() @@ -248,7 +282,17 @@ func (m *Manager) StartMiner(minerType string, config *Config) (Miner, error) { } } + // Emit starting event before actually starting + m.emitEvent(EventMinerStarting, MinerEventData{ + Name: instanceName, + }) + if err := miner.Start(config); err != nil { + // Emit error event + m.emitEvent(EventMinerError, MinerEventData{ + Name: instanceName, + Error: err.Error(), + }) return nil, err } @@ -261,6 +305,11 @@ func (m *Manager) StartMiner(minerType string, config *Config) (Miner, error) { logMessage := fmt.Sprintf("CryptoCurrency Miner started: %s (Binary: %s)", miner.GetName(), miner.GetBinaryPath()) logToSyslog(logMessage) + // Emit started event + m.emitEvent(EventMinerStarted, MinerEventData{ + Name: instanceName, + }) + return miner, nil } @@ -373,6 +422,11 @@ func (m *Manager) StopMiner(name string) error { return fmt.Errorf("miner not found: %s", name) } + // Emit stopping event + m.emitEvent(EventMinerStopping, MinerEventData{ + Name: name, + }) + // Try to stop the miner, but always remove it from the map // This handles the case where a miner crashed or was killed externally stopErr := miner.Stop() @@ -380,6 +434,16 @@ func (m *Manager) StopMiner(name string) error { // Always remove from map - if it's not running, we still want to clean it up delete(m.miners, name) + // Emit stopped event + reason := "stopped" + if stopErr != nil && stopErr.Error() != "miner is not running" { + reason = stopErr.Error() + } + m.emitEvent(EventMinerStopped, MinerEventData{ + Name: name, + Reason: reason, + }) + // Only return error if it wasn't just "miner is not running" if stopErr != nil && stopErr.Error() != "miner is not running" { return stopErr @@ -410,6 +474,29 @@ func (m *Manager) ListMiners() []Miner { return miners } +// RegisterMiner registers an already-started miner with the manager. +// This is useful for simulated miners or externally managed miners. +func (m *Manager) RegisterMiner(miner Miner) error { + name := miner.GetName() + + m.mu.Lock() + if _, exists := m.miners[name]; exists { + m.mu.Unlock() + return fmt.Errorf("miner %s is already registered", name) + } + m.miners[name] = miner + m.mu.Unlock() + + log.Printf("Registered miner: %s", name) + + // Emit miner started event (outside lock) + m.emitEvent(EventMinerStarted, map[string]interface{}{ + "name": name, + }) + + return nil +} + // ListAvailableMiners returns a list of available miners that can be started. func (m *Manager) ListAvailableMiners() []AvailableMiner { return []AvailableMiner{ @@ -506,6 +593,17 @@ func (m *Manager) collectMinerStats() { log.Printf("Warning: failed to persist hashrate for %s: %v", minerName, err) } } + + // Emit stats event for real-time WebSocket updates + m.emitEvent(EventMinerStats, MinerStatsData{ + Name: minerName, + Hashrate: stats.Hashrate, + Shares: stats.Shares, + Rejected: stats.Rejected, + Uptime: stats.Uptime, + Algorithm: stats.Algorithm, + DiffCurrent: stats.DiffCurrent, + }) } } diff --git a/pkg/mining/service.go b/pkg/mining/service.go index f048800..a0d007c 100644 --- a/pkg/mining/service.go +++ b/pkg/mining/service.go @@ -19,6 +19,7 @@ import ( "github.com/adrg/xdg" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/shirou/gopsutil/v4/mem" "github.com/swaggo/swag" @@ -31,6 +32,7 @@ type Service struct { Manager ManagerInterface ProfileManager *ProfileManager NodeService *NodeService + EventHub *EventHub Router *gin.Engine Server *http.Server DisplayAddr string @@ -39,6 +41,23 @@ type Service struct { SwaggerUIPath string } +// WebSocket upgrader for the events endpoint +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // Allow connections from localhost origins + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + // Allow localhost with any port + return strings.Contains(origin, "localhost") || + strings.Contains(origin, "127.0.0.1") || + strings.Contains(origin, "wails.localhost") + }, +} + // NewService creates a new mining service func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) (*Service, error) { apiBasePath := "/" + strings.Trim(swaggerNamespace, "/") @@ -63,10 +82,20 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string, // Continue without node service - P2P features will be unavailable } + // Initialize event hub for WebSocket real-time updates + eventHub := NewEventHub() + go eventHub.Run() + + // Wire up event hub to manager for miner events + if mgr, ok := manager.(*Manager); ok { + mgr.SetEventHub(eventHub) + } + return &Service{ Manager: manager, ProfileManager: profileManager, NodeService: nodeService, + EventHub: eventHub, Server: &http.Server{ Addr: listenAddr, ReadTimeout: 30 * time.Second, @@ -215,6 +244,12 @@ func (s *Service) SetupRoutes() { profilesGroup.POST("/:id/start", s.handleStartMinerWithProfile) } + // WebSocket endpoint for real-time events + wsGroup := apiGroup.Group("/ws") + { + wsGroup.GET("/events", s.handleWebSocketEvents) + } + // Add P2P node endpoints if node service is available if s.NodeService != nil { s.NodeService.SetupRoutes(apiGroup) @@ -816,3 +851,21 @@ func (s *Service) handleMinerHistoricalHashrate(c *gin.Context) { c.JSON(http.StatusOK, history) } + +// handleWebSocketEvents godoc +// @Summary WebSocket endpoint for real-time mining events +// @Description Upgrade to WebSocket for real-time mining stats and events. +// @Description Events include: miner.starting, miner.started, miner.stopping, miner.stopped, miner.stats, miner.error +// @Tags websocket +// @Success 101 {string} string "Switching Protocols" +// @Router /ws/events [get] +func (s *Service) handleWebSocketEvents(c *gin.Context) { + conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("[WebSocket] Failed to upgrade connection: %v", err) + return + } + + log.Printf("[WebSocket] New connection from %s", c.Request.RemoteAddr) + s.EventHub.ServeWs(conn) +} diff --git a/pkg/mining/simulated_miner.go b/pkg/mining/simulated_miner.go new file mode 100644 index 0000000..4445ba4 --- /dev/null +++ b/pkg/mining/simulated_miner.go @@ -0,0 +1,447 @@ +package mining + +import ( + "context" + "fmt" + "math" + "math/rand" + "sync" + "time" +) + +// SimulatedMiner is a mock miner that generates realistic-looking stats for UI testing. +type SimulatedMiner struct { + // Exported fields for JSON serialization + Name string `json:"name"` + Version string `json:"version"` + URL string `json:"url"` + Path string `json:"path"` + MinerBinary string `json:"miner_binary"` + Running bool `json:"running"` + Algorithm string `json:"algorithm"` + HashrateHistory []HashratePoint `json:"hashrateHistory"` + LowResHistory []HashratePoint `json:"lowResHashrateHistory"` + Stats *PerformanceMetrics `json:"stats,omitempty"` + FullStats *XMRigSummary `json:"full_stats,omitempty"` // XMRig-compatible format for UI + + // Internal fields (not exported) + baseHashrate int + peakHashrate int + variance float64 + startTime time.Time + shares int + rejected int + logs []string + mu sync.RWMutex + stopChan chan struct{} + poolName string + difficultyBase int +} + +// SimulatedMinerConfig holds configuration for creating a simulated miner. +type SimulatedMinerConfig struct { + Name string // Miner instance name (e.g., "sim-xmrig-001") + Algorithm string // Algorithm name (e.g., "rx/0", "kawpow", "ethash") + BaseHashrate int // Base hashrate in H/s + Variance float64 // Variance as percentage (0.0-0.2 for 20% variance) + PoolName string // Simulated pool name + Difficulty int // Base difficulty +} + +// NewSimulatedMiner creates a new simulated miner instance. +func NewSimulatedMiner(config SimulatedMinerConfig) *SimulatedMiner { + if config.Variance <= 0 { + config.Variance = 0.1 // Default 10% variance + } + if config.PoolName == "" { + config.PoolName = "sim-pool.example.com:3333" + } + if config.Difficulty <= 0 { + config.Difficulty = 10000 + } + + return &SimulatedMiner{ + Name: config.Name, + Version: "1.0.0-simulated", + URL: "https://github.com/simulated/miner", + Path: "/simulated/miner", + MinerBinary: "/simulated/miner/sim-miner", + Algorithm: config.Algorithm, + HashrateHistory: make([]HashratePoint, 0), + LowResHistory: make([]HashratePoint, 0), + baseHashrate: config.BaseHashrate, + variance: config.Variance, + poolName: config.PoolName, + difficultyBase: config.Difficulty, + logs: make([]string, 0), + } +} + +// Install is a no-op for simulated miners. +func (m *SimulatedMiner) Install() error { + return nil +} + +// Uninstall is a no-op for simulated miners. +func (m *SimulatedMiner) Uninstall() error { + return nil +} + +// Start begins the simulated mining process. +func (m *SimulatedMiner) Start(config *Config) error { + m.mu.Lock() + if m.Running { + m.mu.Unlock() + return fmt.Errorf("simulated miner %s is already running", m.Name) + } + + m.Running = true + m.startTime = time.Now() + m.shares = 0 + m.rejected = 0 + m.stopChan = make(chan struct{}) + m.HashrateHistory = make([]HashratePoint, 0) + m.LowResHistory = make([]HashratePoint, 0) + m.logs = []string{ + fmt.Sprintf("[%s] Simulated miner starting...", time.Now().Format("15:04:05")), + fmt.Sprintf("[%s] Connecting to %s", time.Now().Format("15:04:05"), m.poolName), + fmt.Sprintf("[%s] Pool connected, algorithm: %s", time.Now().Format("15:04:05"), m.Algorithm), + } + m.mu.Unlock() + + // Start background simulation + go m.runSimulation() + + return nil +} + +// Stop stops the simulated miner. +func (m *SimulatedMiner) Stop() error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.Running { + return fmt.Errorf("simulated miner %s is not running", m.Name) + } + + close(m.stopChan) + m.Running = false + m.logs = append(m.logs, fmt.Sprintf("[%s] Miner stopped", time.Now().Format("15:04:05"))) + + return nil +} + +// runSimulation runs the background simulation loop. +func (m *SimulatedMiner) runSimulation() { + ticker := time.NewTicker(HighResolutionInterval) + defer ticker.Stop() + + shareTicker := time.NewTicker(time.Duration(5+rand.Intn(10)) * time.Second) + defer shareTicker.Stop() + + for { + select { + case <-m.stopChan: + return + case <-ticker.C: + m.updateHashrate() + case <-shareTicker.C: + m.simulateShare() + // Randomize next share time + shareTicker.Reset(time.Duration(5+rand.Intn(15)) * time.Second) + } + } +} + +// updateHashrate generates a new hashrate value with realistic variation. +func (m *SimulatedMiner) updateHashrate() { + m.mu.Lock() + defer m.mu.Unlock() + + // Generate hashrate with variance and smooth transitions + now := time.Now() + uptime := now.Sub(m.startTime).Seconds() + + // Ramp up period (first 30 seconds) + rampFactor := math.Min(1.0, uptime/30.0) + + // Add some sine wave variation for realistic fluctuation + sineVariation := math.Sin(uptime/10) * 0.05 + + // Random noise + noise := (rand.Float64() - 0.5) * 2 * m.variance + + // Calculate final hashrate + hashrate := int(float64(m.baseHashrate) * rampFactor * (1.0 + sineVariation + noise)) + if hashrate < 0 { + hashrate = 0 + } + + point := HashratePoint{ + Timestamp: now, + Hashrate: hashrate, + } + + m.HashrateHistory = append(m.HashrateHistory, point) + + // Track peak hashrate + if hashrate > m.peakHashrate { + m.peakHashrate = hashrate + } + + // Update stats for JSON serialization + uptimeInt := int(uptime) + diffCurrent := m.difficultyBase + rand.Intn(m.difficultyBase/2) + + m.Stats = &PerformanceMetrics{ + Hashrate: hashrate, + Shares: m.shares, + Rejected: m.rejected, + Uptime: uptimeInt, + Algorithm: m.Algorithm, + AvgDifficulty: m.difficultyBase, + DiffCurrent: diffCurrent, + } + + // Update XMRig-compatible full_stats for UI + m.FullStats = &XMRigSummary{ + ID: m.Name, + WorkerID: m.Name, + Uptime: uptimeInt, + Algo: m.Algorithm, + Version: m.Version, + } + m.FullStats.Hashrate.Total = []float64{float64(hashrate)} + m.FullStats.Hashrate.Highest = float64(m.peakHashrate) + m.FullStats.Results.SharesGood = m.shares + m.FullStats.Results.SharesTotal = m.shares + m.rejected + m.FullStats.Results.DiffCurrent = diffCurrent + m.FullStats.Results.AvgTime = 15 + rand.Intn(10) // Simulated avg share time + m.FullStats.Results.HashesTotal = m.shares * diffCurrent + m.FullStats.Connection.Pool = m.poolName + m.FullStats.Connection.Uptime = uptimeInt + m.FullStats.Connection.Diff = diffCurrent + m.FullStats.Connection.Accepted = m.shares + m.FullStats.Connection.Rejected = m.rejected + m.FullStats.Connection.Algo = m.Algorithm + m.FullStats.Connection.Ping = 50 + rand.Intn(50) + + // Trim high-res history to last 5 minutes + cutoff := now.Add(-HighResolutionDuration) + for len(m.HashrateHistory) > 0 && m.HashrateHistory[0].Timestamp.Before(cutoff) { + m.HashrateHistory = m.HashrateHistory[1:] + } +} + +// simulateShare simulates finding a share. +func (m *SimulatedMiner) simulateShare() { + m.mu.Lock() + defer m.mu.Unlock() + + // 2% chance of rejected share + if rand.Float64() < 0.02 { + m.rejected++ + m.logs = append(m.logs, fmt.Sprintf("[%s] Share rejected (stale)", time.Now().Format("15:04:05"))) + } else { + m.shares++ + diff := m.difficultyBase + rand.Intn(m.difficultyBase/2) + m.logs = append(m.logs, fmt.Sprintf("[%s] Share accepted (%d/%d) diff %d", time.Now().Format("15:04:05"), m.shares, m.rejected, diff)) + } + + // Keep last 100 log lines + if len(m.logs) > 100 { + m.logs = m.logs[len(m.logs)-100:] + } +} + +// GetStats returns current performance metrics. +func (m *SimulatedMiner) GetStats(ctx context.Context) (*PerformanceMetrics, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.Running { + return nil, fmt.Errorf("simulated miner %s is not running", m.Name) + } + + // Calculate current hashrate from recent history + var hashrate int + if len(m.HashrateHistory) > 0 { + hashrate = m.HashrateHistory[len(m.HashrateHistory)-1].Hashrate + } + + uptime := int(time.Since(m.startTime).Seconds()) + + // Calculate average difficulty + avgDiff := m.difficultyBase + if m.shares > 0 { + avgDiff = m.difficultyBase + rand.Intn(m.difficultyBase/4) + } + + return &PerformanceMetrics{ + Hashrate: hashrate, + Shares: m.shares, + Rejected: m.rejected, + Uptime: uptime, + LastShare: time.Now().Unix() - int64(rand.Intn(30)), + Algorithm: m.Algorithm, + AvgDifficulty: avgDiff, + DiffCurrent: m.difficultyBase + rand.Intn(m.difficultyBase/2), + ExtraData: map[string]interface{}{ + "pool": m.poolName, + "simulated": true, + }, + }, nil +} + +// GetName returns the miner's name. +func (m *SimulatedMiner) GetName() string { + return m.Name +} + +// GetPath returns a simulated path. +func (m *SimulatedMiner) GetPath() string { + return m.Path +} + +// GetBinaryPath returns a simulated binary path. +func (m *SimulatedMiner) GetBinaryPath() string { + return m.MinerBinary +} + +// CheckInstallation returns simulated installation details. +func (m *SimulatedMiner) CheckInstallation() (*InstallationDetails, error) { + return &InstallationDetails{ + IsInstalled: true, + Version: "1.0.0-simulated", + Path: "/simulated/miner", + MinerBinary: "simulated-miner", + ConfigPath: "/simulated/config.json", + }, nil +} + +// GetLatestVersion returns a simulated version. +func (m *SimulatedMiner) GetLatestVersion() (string, error) { + return "1.0.0-simulated", nil +} + +// GetHashrateHistory returns the hashrate history. +func (m *SimulatedMiner) GetHashrateHistory() []HashratePoint { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]HashratePoint, len(m.HashrateHistory)) + copy(result, m.HashrateHistory) + return result +} + +// AddHashratePoint adds a point to the history. +func (m *SimulatedMiner) AddHashratePoint(point HashratePoint) { + m.mu.Lock() + defer m.mu.Unlock() + m.HashrateHistory = append(m.HashrateHistory, point) +} + +// ReduceHashrateHistory reduces the history (called by manager). +func (m *SimulatedMiner) ReduceHashrateHistory(now time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + + // Move old high-res points to low-res + cutoff := now.Add(-HighResolutionDuration) + var toMove []HashratePoint + + newHistory := make([]HashratePoint, 0) + for _, point := range m.HashrateHistory { + if point.Timestamp.Before(cutoff) { + toMove = append(toMove, point) + } else { + newHistory = append(newHistory, point) + } + } + m.HashrateHistory = newHistory + + // Average the old points and add to low-res + if len(toMove) > 0 { + var sum int + for _, p := range toMove { + sum += p.Hashrate + } + avg := sum / len(toMove) + m.LowResHistory = append(m.LowResHistory, HashratePoint{ + Timestamp: toMove[len(toMove)-1].Timestamp, + Hashrate: avg, + }) + } + + // Trim low-res history + lowResCutoff := now.Add(-LowResHistoryRetention) + newLowRes := make([]HashratePoint, 0) + for _, point := range m.LowResHistory { + if !point.Timestamp.Before(lowResCutoff) { + newLowRes = append(newLowRes, point) + } + } + m.LowResHistory = newLowRes +} + +// GetLogs returns the simulated logs. +func (m *SimulatedMiner) GetLogs() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]string, len(m.logs)) + copy(result, m.logs) + return result +} + +// WriteStdin simulates stdin input. +func (m *SimulatedMiner) WriteStdin(input string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.Running { + return fmt.Errorf("simulated miner %s is not running", m.Name) + } + + m.logs = append(m.logs, fmt.Sprintf("[%s] stdin: %s", time.Now().Format("15:04:05"), input)) + return nil +} + +// SimulatedMinerPresets provides common presets for simulated miners. +var SimulatedMinerPresets = map[string]SimulatedMinerConfig{ + "cpu-low": { + Algorithm: "rx/0", + BaseHashrate: 500, + Variance: 0.15, + PoolName: "pool.hashvault.pro:443", + Difficulty: 50000, + }, + "cpu-medium": { + Algorithm: "rx/0", + BaseHashrate: 5000, + Variance: 0.10, + PoolName: "pool.hashvault.pro:443", + Difficulty: 100000, + }, + "cpu-high": { + Algorithm: "rx/0", + BaseHashrate: 15000, + Variance: 0.08, + PoolName: "pool.hashvault.pro:443", + Difficulty: 200000, + }, + "gpu-ethash": { + Algorithm: "ethash", + BaseHashrate: 30000000, // 30 MH/s + Variance: 0.05, + PoolName: "eth.2miners.com:2020", + Difficulty: 4000000000, + }, + "gpu-kawpow": { + Algorithm: "kawpow", + BaseHashrate: 15000000, // 15 MH/s + Variance: 0.06, + PoolName: "rvn.2miners.com:6060", + Difficulty: 1000000000, + }, +} diff --git a/ui/src/app/chart.component.ts b/ui/src/app/chart.component.ts index 89f036d..148e045 100644 --- a/ui/src/app/chart.component.ts +++ b/ui/src/app/chart.component.ts @@ -74,8 +74,29 @@ export class ChartComponent { // Create effect with proper cleanup const effectRef = effect(() => { - // Use 24-hour historical data from database - const historyMap = this.minerService.historicalHashrate(); + // Hybrid approach: use live in-memory data when available, fall back to database historical data + const liveHistory = this.minerService.hashrateHistory(); + const dbHistory = this.minerService.historicalHashrate(); + + // Merge: prefer live data, supplement with historical for longer time ranges + const historyMap = new Map(); + + // First, add all historical data as base + dbHistory.forEach((points, name) => { + historyMap.set(name, [...points]); + }); + + // Then overlay/replace with live data (more recent and accurate) + liveHistory.forEach((points, name) => { + if (points.length > 0) { + const existing = historyMap.get(name) || []; + // Get the earliest live data timestamp + const earliestLive = points.length > 0 ? new Date(points[0].timestamp).getTime() : Infinity; + // Keep historical points before live data starts, then use all live data + const historicalBefore = existing.filter(p => new Date(p.timestamp).getTime() < earliestLive); + historyMap.set(name, [...historicalBefore, ...points]); + } + }); // Clean up colors for miners no longer active const activeNames = new Set(historyMap.keys()); diff --git a/ui/src/app/components/miner-switcher/miner-switcher.component.ts b/ui/src/app/components/miner-switcher/miner-switcher.component.ts index c928fbd..fd16f05 100644 --- a/ui/src/app/components/miner-switcher/miner-switcher.component.ts +++ b/ui/src/app/components/miner-switcher/miner-switcher.component.ts @@ -1,6 +1,8 @@ -import { Component, inject, computed, signal, output, HostListener } from '@angular/core'; +import { Component, inject, computed, signal, output, HostListener, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Subscription } from 'rxjs'; import { MinerService } from '../../miner.service'; +import { WebSocketService } from '../../websocket.service'; interface ContextMenuState { visible: boolean; @@ -60,39 +62,49 @@ const SPINNER_SVG = `
{{ miner.name }} {{ formatHashrate(getHashrate(miner)) }} -
- + } @else { + - + + + }
} @@ -110,24 +122,36 @@ const SPINNER_SVG = ` Start Worker @for (profile of profiles(); track profile.id) { - @if (isLoading('start-' + profile.id)) { - - - - } @else { - - - - + } - {{ profile.name }} - {{ profile.minerType }} - + } } @@ -363,15 +387,41 @@ const SPINNER_SVG = `(); @@ -573,6 +676,76 @@ export class MinerSwitcherComponent { // Track loading states for actions (e.g., "stop-minerName", "start-profileId") private loadingActions = signal>(new Set()); + // Track pending start actions to match profile IDs with miner names + private pendingStarts = new Map(); // profileId -> expected miner type + + constructor() { + this.subscribeToWebSocketEvents(); + } + + ngOnDestroy(): void { + this.wsSubscriptions.forEach(sub => sub.unsubscribe()); + } + + /** + * Subscribe to WebSocket events to clear loading states when actions complete + */ + private subscribeToWebSocketEvents(): void { + // When a miner starts, clear the loading state for its profile + const startedSub = this.ws.minerStarted$.subscribe(data => { + console.log('[MinerSwitcher] Miner started event:', data.name); + // Clear any start loading states that might match this miner + this.loadingActions.update(set => { + const newSet = new Set(set); + // Clear all start-* loading states since we got a miner.started event + for (const key of newSet) { + if (key.startsWith('start-')) { + newSet.delete(key); + } + } + return newSet; + }); + this.pendingStarts.clear(); + }); + this.wsSubscriptions.push(startedSub); + + // When a miner stops, clear the loading state + const stoppedSub = this.ws.minerStopped$.subscribe(data => { + console.log('[MinerSwitcher] Miner stopped event:', data.name); + const actionKey = `stop-${data.name}`; + this.setLoading(actionKey, false); + + // If this was the selected miner, switch to all view + if (this.selectedMinerName() === data.name) { + this.minerService.selectAllMiners(); + } + + // Close context menu if it was for this miner + if (this.contextMenu().minerName === data.name) { + this.closeContextMenu(); + } + }); + this.wsSubscriptions.push(stoppedSub); + + // On error, clear relevant loading states + const errorSub = this.ws.minerError$.subscribe(data => { + console.log('[MinerSwitcher] Miner error event:', data.name, data.error); + // Clear both start and stop loading states for this miner + this.loadingActions.update(set => { + const newSet = new Set(set); + newSet.delete(`stop-${data.name}`); + // Also clear any pending starts + for (const key of newSet) { + if (key.startsWith('start-')) { + newSet.delete(key); + } + } + return newSet; + }); + }); + this.wsSubscriptions.push(errorSub); + } + isLoading(actionKey: string): boolean { return this.loadingActions().has(actionKey); } @@ -634,13 +807,11 @@ export class MinerSwitcherComponent { this.setLoading(actionKey, true); this.minerService.stopMiner(name).subscribe({ next: () => { - // If this was the selected miner, switch to all view - if (this.selectedMinerName() === name) { - this.minerService.selectAllMiners(); - } - this.setLoading(actionKey, false); + // Loading state will be cleared by WebSocket miner.stopped event + // Keep spinner spinning until we get confirmation the miner actually stopped }, error: () => { + // On HTTP error, clear loading state immediately this.setLoading(actionKey, false); } }); @@ -661,17 +832,40 @@ export class MinerSwitcherComponent { if (this.isLoading(actionKey)) return; this.setLoading(actionKey, true); + this.pendingStarts.set(profileId, profileName); + this.minerService.startMiner(profileId).subscribe({ next: () => { - this.setLoading(actionKey, false); + // Loading state will be cleared by WebSocket miner.started event + // Keep spinner spinning until we get confirmation the miner actually started this.closeDropdown(); }, error: () => { + // On HTTP error, clear loading state immediately this.setLoading(actionKey, false); + this.pendingStarts.delete(profileId); } }); } + /** + * Cancel an in-progress action (clears loading state) + * For start actions, the miner may still start - this just clears the UI state + */ + cancelAction(event: Event, actionKey: string) { + event.stopPropagation(); + event.preventDefault(); + + // Clear the loading state + this.setLoading(actionKey, false); + + // If it was a start action, clear pending starts + if (actionKey.startsWith('start-')) { + const profileId = actionKey.replace('start-', ''); + this.pendingStarts.delete(profileId); + } + } + getHashrate(miner: any): number { return miner.full_stats?.hashrate?.total?.[0] || 0; } @@ -741,13 +935,11 @@ export class MinerSwitcherComponent { this.setLoading(actionKey, true); this.minerService.stopMiner(minerName).subscribe({ next: () => { - if (this.selectedMinerName() === minerName) { - this.minerService.selectAllMiners(); - } - this.setLoading(actionKey, false); - this.closeContextMenu(); + // Loading state and context menu will be cleared by WebSocket miner.stopped event + // Keep spinner spinning until we get confirmation the miner actually stopped }, error: () => { + // On HTTP error, clear loading state immediately this.setLoading(actionKey, false); this.closeContextMenu(); } diff --git a/ui/src/app/miner.service.ts b/ui/src/app/miner.service.ts index 6c1d60d..868234b 100644 --- a/ui/src/app/miner.service.ts +++ b/ui/src/app/miner.service.ts @@ -1,7 +1,8 @@ -import { Injectable, OnDestroy, signal, computed } from '@angular/core'; +import { Injectable, OnDestroy, signal, computed, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { of, forkJoin, Subscription, interval } from 'rxjs'; -import { switchMap, catchError, map, tap } from 'rxjs/operators'; +import { of, forkJoin, Subscription, interval, merge } from 'rxjs'; +import { switchMap, catchError, map, tap, filter, debounceTime } from 'rxjs/operators'; +import { WebSocketService, MinerEventData, MinerStatsData } from './websocket.service'; // --- Interfaces --- export interface InstallationDetails { @@ -47,6 +48,8 @@ export interface SystemState { export class MinerService implements OnDestroy { private apiBaseUrl = 'http://localhost:9090/api/v1/mining'; private pollingSubscription?: Subscription; + private wsSubscriptions: Subscription[] = []; + private ws = inject(WebSocketService); // --- State Signals --- public state = signal({ @@ -111,11 +114,71 @@ export class MinerService implements OnDestroy { this.forceRefreshState(); this.startPollingLive_Data(); this.startPollingHistoricalData(); + this.subscribeToWebSocketEvents(); } ngOnDestroy(): void { this.stopPolling(); this.historyPollingSubscription?.unsubscribe(); + this.wsSubscriptions.forEach(sub => sub.unsubscribe()); + } + + // --- WebSocket Event Subscriptions --- + + /** + * Subscribe to WebSocket events for real-time updates. + * This supplements polling with instant event-driven updates. + */ + private subscribeToWebSocketEvents(): void { + // Listen for miner started/stopped events to refresh the miner list immediately + const minerLifecycleEvents = merge( + this.ws.minerStarted$, + this.ws.minerStopped$ + ).pipe( + debounceTime(500) // Debounce to avoid rapid-fire updates + ).subscribe(() => { + // Refresh running miners when a miner starts or stops + this.getRunningMiners().pipe( + catchError(() => of([])) + ).subscribe(runningMiners => { + this.state.update(s => ({ ...s, runningMiners })); + this.updateHashrateHistory(runningMiners); + }); + }); + this.wsSubscriptions.push(minerLifecycleEvents); + + // Listen for stats events to update hashrates in real-time + // This provides more immediate updates than the 5-second polling interval + const statsSubscription = this.ws.minerStats$.subscribe((stats: MinerStatsData) => { + // Update the running miners with fresh hashrate data + this.state.update(s => { + const runningMiners = s.runningMiners.map(miner => { + if (miner.name === stats.name) { + return { + ...miner, + stats: { + ...miner.stats, + hashrate: stats.hashrate, + shares: stats.shares, + rejected: stats.rejected, + uptime: stats.uptime, + algorithm: stats.algorithm || miner.stats?.algorithm, + } + }; + } + return miner; + }); + return { ...s, runningMiners }; + }); + }); + this.wsSubscriptions.push(statsSubscription); + + // Listen for error events to show notifications + const errorSubscription = this.ws.minerError$.subscribe((data: MinerEventData) => { + console.error(`[MinerService] Miner error for ${data.name}:`, data.error); + // Notification can be handled by components listening to this event + }); + this.wsSubscriptions.push(errorSubscription); } // --- Data Loading and Polling Logic --- @@ -136,7 +199,6 @@ export class MinerService implements OnDestroy { if (initialState) { this.state.set(initialState); this.updateHashrateHistory(initialState.runningMiners); - // Fetch historical data now that we know which miners are running this.fetchHistoricalHashrate(); } }); diff --git a/ui/src/app/pages/miners/miners.component.ts b/ui/src/app/pages/miners/miners.component.ts index fa4ce9e..1353c58 100644 --- a/ui/src/app/pages/miners/miners.component.ts +++ b/ui/src/app/pages/miners/miners.component.ts @@ -1,8 +1,21 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MinerService } from '../../miner.service'; import { NotificationService } from '../../notification.service'; +interface MinerInfo { + type: string; + name: string; + description: string; + version: string; + installed: boolean; + algorithms: string[]; + recommended: boolean; + homepage: string; + license: string; + placeholder?: boolean; // True for miners not yet supported by backend +} + @Component({ selector: 'app-miners', standalone: true, @@ -16,66 +29,175 @@ import { NotificationService } from '../../notification.service'; -
- @for (miner of availableMiners(); track miner.type) { -
-
- - - -
- -
-

{{ miner.name }}

-

{{ miner.description }}

- -
- @if (miner.version) { - v{{ miner.version }} - } - @if (miner.algorithms.length > 0) { - {{ miner.algorithms.join(', ') }} - } -
-
- -
- @if (!miner.installed) { - - } @else { -
- + + @if (featuredMiners().length > 0) { +
+

Installed & Recommended

+
+ -
- } -
+ } +
+ } + + + @if (otherMiners().length > 0) { +
+

Other Available Miners

+
+
+ @for (miner of otherMiners(); track miner.type) { +
+
+ + + +
+ +
+
+

{{ miner.name }}

+ @if (miner.placeholder) { + Coming Soon + } +
+

{{ miner.description }}

+
+ @for (algo of miner.algorithms.slice(0, 3); track algo) { + {{ algo }} + } + @if (miner.algorithms.length > 3) { + +{{ miner.algorithms.length - 3 }} + } + @if (miner.homepage) { + + + + + GitHub + + } +
+
+ +
+ @if (miner.placeholder) { + + + + + Planned + + } @else { + + } +
+
+ } +
+ } @if (systemInfo()) { @@ -122,9 +244,29 @@ import { NotificationService } from '../../notification.service'; color: #64748b; } + .section-header { + margin-bottom: 0.5rem; + } + + .section-header h3 { + font-size: 0.875rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + /* Featured miners grid - larger cards */ + .featured-miners-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.25rem; + } + + /* Regular miners grid - compact cards */ .miners-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; } @@ -133,54 +275,168 @@ import { NotificationService } from '../../notification.service'; gap: 1rem; padding: 1.25rem; background: var(--color-surface-100); - border-radius: 0.5rem; - border: 1px solid rgb(37 37 66 / 0.2); - transition: border-color 0.15s ease; + border-radius: 0.75rem; + border: 1px solid rgb(37 37 66 / 0.3); + transition: all 0.2s ease; } - .miner-card.installed { - border-color: rgb(16 185 129 / 0.2); + .miner-card:hover { + border-color: rgb(37 37 66 / 0.5); + } + + /* Featured card styling */ + .miner-card.featured { + flex-direction: column; + position: relative; + padding: 1.5rem; + background: linear-gradient(135deg, var(--color-surface-100) 0%, rgb(25 25 45) 100%); + border: 1px solid rgb(0 212 255 / 0.15); + overflow: hidden; + } + + .miner-card.featured::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--color-accent-500), transparent); + } + + .miner-card.featured.installed { + border-color: rgb(16 185 129 / 0.3); + } + + .miner-card.featured.installed::before { + background: linear-gradient(90deg, var(--color-success-500), transparent); + } + + .featured-ribbon { + position: absolute; + top: 0.75rem; + right: 0.75rem; + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.625rem; + background: rgb(16 185 129 / 0.15); + border-radius: 1rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-success-500); + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .featured-ribbon.recommended { + background: rgb(251 191 36 / 0.15); + color: #fbbf24; + } + + .featured-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; } .miner-icon { display: flex; align-items: center; justify-content: center; - width: 56px; - height: 56px; + width: 48px; + height: 48px; background: rgb(0 212 255 / 0.1); border-radius: 0.5rem; color: var(--color-accent-500); flex-shrink: 0; } + .miner-icon.large { + width: 56px; + height: 56px; + border-radius: 0.75rem; + background: linear-gradient(135deg, rgb(0 212 255 / 0.15) 0%, rgb(0 212 255 / 0.05) 100%); + } + + .featured-title { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .featured-title h3 { + font-size: 1.125rem; + font-weight: 600; + color: white; + } + + .version-badge { + display: inline-flex; + padding: 0.125rem 0.5rem; + background: rgb(37 37 66 / 0.5); + border-radius: 0.25rem; + font-size: 0.6875rem; + color: #94a3b8; + font-family: var(--font-family-mono); + width: fit-content; + } + .miner-info { flex: 1; min-width: 0; } .miner-info h3 { - font-size: 1rem; + font-size: 0.9375rem; font-weight: 600; color: white; } + .miner-name-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .coming-soon-badge { + padding: 0.125rem 0.5rem; + background: rgb(251 191 36 / 0.15); + border-radius: 0.25rem; + font-size: 0.625rem; + font-weight: 600; + color: #fbbf24; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .miner-description { margin-top: 0.25rem; font-size: 0.8125rem; color: #64748b; - line-height: 1.4; + line-height: 1.5; + } + + .miner-card.featured .miner-description { + margin-bottom: 1rem; } .miner-meta { display: flex; flex-wrap: wrap; gap: 0.375rem; - margin-top: 0.75rem; + margin-top: 0.5rem; + } + + .miner-card.featured .miner-meta { + margin-top: 0; + margin-bottom: 1rem; } .meta-badge { - padding: 0.125rem 0.5rem; + padding: 0.1875rem 0.5rem; background: rgb(37 37 66 / 0.5); border-radius: 0.25rem; font-size: 0.6875rem; @@ -192,14 +448,95 @@ import { NotificationService } from '../../notification.service'; color: var(--color-accent-500); } + .meta-badge.link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + text-decoration: none; + cursor: pointer; + transition: all 0.15s ease; + } + + .meta-badge.link:hover { + background: rgb(37 37 66 / 0.8); + color: white; + } + + /* Placeholder card styles */ + .miner-card.placeholder { + opacity: 0.75; + border-style: dashed; + } + + .miner-card.placeholder:hover { + opacity: 0.9; + } + + .placeholder-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: rgb(37 37 66 / 0.3); + border-radius: 0.375rem; + font-size: 0.75rem; + color: #64748b; + border: 1px dashed rgb(37 37 66 / 0.5); + } + + .featured-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid rgb(37 37 66 / 0.3); + } + + .miner-links { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .link-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: rgb(37 37 66 / 0.3); + border-radius: 0.25rem; + font-size: 0.6875rem; + color: #94a3b8; + text-decoration: none; + transition: all 0.15s ease; + } + + .link-badge:hover { + background: rgb(37 37 66 / 0.5); + color: white; + } + + .license-badge { + padding: 0.25rem 0.5rem; + background: rgb(139 92 246 / 0.1); + border-radius: 0.25rem; + font-size: 0.6875rem; + color: #a78bfa; + } + .miner-actions { display: flex; - flex-direction: column; - align-items: flex-end; + align-items: center; gap: 0.5rem; flex-shrink: 0; } + .miner-card.compact .miner-actions { + flex-direction: column; + align-items: flex-end; + } + .btn { display: inline-flex; align-items: center; @@ -228,6 +565,15 @@ import { NotificationService } from '../../notification.service'; background: rgb(0 212 255 / 0.8); } + .btn-secondary { + background: rgb(37 37 66 / 0.5); + color: #e2e8f0; + } + + .btn-secondary:hover:not(:disabled) { + background: rgb(37 37 66 / 0.8); + } + .btn-outline { background: transparent; border: 1px solid rgb(37 37 66 / 0.3); @@ -239,15 +585,15 @@ import { NotificationService } from '../../notification.service'; color: white; } - .installed-badge { - display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.5rem 0.75rem; - background: rgb(16 185 129 / 0.1); - border-radius: 0.375rem; - font-size: 0.8125rem; - color: var(--color-success-500); + .btn-outline-danger { + background: transparent; + border: 1px solid rgb(239 68 68 / 0.3); + color: #f87171; + } + + .btn-outline-danger:hover { + background: rgb(239 68 68 / 0.1); + border-color: rgb(239 68 68 / 0.5); } .animate-spin { @@ -261,28 +607,38 @@ import { NotificationService } from '../../notification.service'; /* Mobile responsive styles */ @media (max-width: 768px) { + .featured-miners-grid, .miners-grid { grid-template-columns: 1fr; } - .miner-card { + .miner-card.compact { flex-direction: column; align-items: flex-start; } - .miner-actions { + .miner-card.compact .miner-actions { flex-direction: row; width: 100%; margin-top: 0.75rem; } - .miner-actions .btn { + .miner-card.compact .miner-actions .btn { flex: 1; justify-content: center; } - .installed-badge { - flex: 1; + .featured-footer { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .miner-links { + justify-content: center; + } + + .featured-footer .miner-actions { justify-content: center; } } @@ -335,14 +691,143 @@ export class MinersComponent { installing = signal(null); - availableMiners = () => this.state().manageableMiners.map((m: any) => ({ - type: m.name, - name: m.name, - description: m.description || this.getMinerDescription(m.name), - version: this.getInstalledVersion(m.name), - installed: m.is_installed, - algorithms: this.getMinerAlgorithms(m.name) - })); + // Miner metadata with descriptions, algorithms, homepage, and license + // Priority: XMRig (CPU, open source), TT-Miner (NVIDIA GPU), then others by popularity + private minerMetadata: Record> = { + // Tier 1: Recommended miners with great API support + 'xmrig': { + description: 'High-performance CPU/GPU miner for RandomX, KawPow, CryptoNight, and more. The most popular open-source miner with excellent HTTP API and stability.', + algorithms: ['RandomX', 'KawPow', 'CryptoNight', 'GhostRider'], + homepage: 'https://github.com/xmrig/xmrig', + license: 'GPL-3.0', + recommended: true + }, + 'tt-miner': { + description: 'High-performance NVIDIA GPU miner with excellent efficiency. Supports Ethash, KawPow, Autolykos2, ProgPow and more algorithms.', + algorithms: ['Ethash', 'KawPow', 'Autolykos2', 'ProgPow'], + homepage: 'https://github.com/TrailingStop/TT-Miner-release', + license: 'Proprietary', + recommended: true + }, + // Tier 2: Popular GPU miners with HTTP APIs + 'trex': { + description: 'NVIDIA-focused miner with excellent HTTP REST API and Web UI. Great performance on Ethash, KawPow, Autolykos2, and Octopus.', + algorithms: ['Ethash', 'KawPow', 'Octopus', 'Autolykos2', 'Blake3', 'FishHash'], + homepage: 'https://github.com/trexminer/T-Rex', + license: 'Proprietary', + recommended: false + }, + 'lolminer': { + description: 'Multi-GPU miner supporting AMD, NVIDIA, and Intel Arc. HTTP JSON API with Web GUI. Excellent Equihash and Beam performance.', + algorithms: ['Ethash', 'Etchash', 'BeamHash', 'Equihash', 'Autolykos2', 'FishHash'], + homepage: 'https://github.com/Lolliedieb/lolMiner-releases', + license: 'Proprietary', + recommended: false + }, + 'rigel': { + description: 'Modern NVIDIA miner with HTTP REST API. Supports Ethash, KawPow, Autolykos2, FishHash, KarlsenHash, and more.', + algorithms: ['Ethash', 'Etchash', 'KawPow', 'Autolykos2', 'FishHash', 'KarlsenHash'], + homepage: 'https://github.com/rigelminer/rigel', + license: 'Proprietary', + recommended: false + }, + 'bzminer': { + description: 'Multi-GPU miner for AMD and NVIDIA with HTTP API and Web GUI. Discord webhook integration for monitoring.', + algorithms: ['KawPow', 'Ethash', 'Etchash', 'Autolykos2', 'Karlsen', 'Alephium'], + homepage: 'https://github.com/bzminer/bzminer', + license: 'Proprietary', + recommended: false + }, + 'srbminer': { + description: 'CPU+GPU miner supporting 100+ algorithms. HTTP API with Web GUI. Works with AMD, NVIDIA, and Intel GPUs.', + algorithms: ['RandomX', 'Ethash', 'Autolykos2', 'KarlsenHash', 'CryptoNight', 'GhostRider'], + homepage: 'https://github.com/doktor83/SRBMiner-Multi', + license: 'Proprietary', + recommended: false + }, + 'teamredminer': { + description: 'AMD-focused GPU miner with Claymore-compatible API. Excellent performance on Ethash, KawPow, and Autolykos2.', + algorithms: ['Ethash', 'Etchash', 'KawPow', 'Autolykos2', 'Karlsen', 'CryptoNight'], + homepage: 'https://github.com/todxx/teamredminer', + license: 'Proprietary', + recommended: false + }, + 'gminer': { + description: 'GPU miner with built-in Web UI. Supports Ethash, Equihash variants, KawPow, and Autolykos2.', + algorithms: ['Ethash', 'ProgPoW', 'KawPow', 'Equihash', 'Autolykos2', 'Beam'], + homepage: 'https://github.com/develsoftware/GMinerRelease', + license: 'Proprietary', + recommended: false + }, + 'nbminer': { + description: 'GPU miner with HTTP REST API and Web Monitor. Supports Ethash, KawPow, BeamV3, Octopus, and more.', + algorithms: ['Ethash', 'Etchash', 'KawPow', 'BeamV3', 'Octopus', 'Autolykos2'], + homepage: 'https://github.com/NebuTech/NBMiner', + license: 'Proprietary', + recommended: false + } + }; + + // All miners with full metadata (from backend + placeholders) + private allMiners = computed(() => { + // Get miners from backend + const backendMiners = this.state().manageableMiners.map((m: any) => { + const meta = this.minerMetadata[m.name] || {}; + return { + type: m.name, + name: m.name, + description: m.description || meta.description || 'Mining software', + version: this.getInstalledVersion(m.name), + installed: m.is_installed, + algorithms: meta.algorithms || [], + recommended: meta.recommended || false, + homepage: meta.homepage || '', + license: meta.license || '', + placeholder: false + }; + }); + + // Get backend miner names for filtering + const backendNames = new Set(backendMiners.map(m => m.type)); + + // Add placeholder miners that aren't from backend + const placeholderMiners = Object.entries(this.minerMetadata) + .filter(([name]) => !backendNames.has(name)) + .map(([name, meta]) => ({ + type: name, + name: name, + description: meta.description || 'Mining software', + version: '', + installed: false, + algorithms: meta.algorithms || [], + recommended: meta.recommended || false, + homepage: meta.homepage || '', + license: meta.license || '', + placeholder: true + })); + + return [...backendMiners, ...placeholderMiners]; + }); + + // Featured miners: installed OR recommended (sorted: installed first) + featuredMiners = computed(() => { + return this.allMiners() + .filter(m => m.installed || m.recommended) + .sort((a, b) => { + // Installed first + if (a.installed && !b.installed) return -1; + if (!a.installed && b.installed) return 1; + // Then by recommended + if (a.recommended && !b.recommended) return -1; + if (!a.recommended && b.recommended) return 1; + return 0; + }); + }); + + // Other miners: not installed AND not recommended + otherMiners = computed(() => { + return this.allMiners().filter(m => !m.installed && !m.recommended); + }); getInstalledVersion(type: string): string { const installed = this.state().installedMiners.find(m => m.type === type); @@ -351,26 +836,6 @@ export class MinersComponent { systemInfo = () => this.state().systemInfo; - getMinerDescription(type: string): string { - const descriptions: Record = { - 'xmrig': 'High-performance RandomX and CryptoNight miner', - 'ttminer': 'NVIDIA GPU miner with broad algorithm support', - 'lolminer': 'Multi-algorithm AMD & NVIDIA miner', - 'trex': 'NVIDIA-focused miner for modern GPUs' - }; - return descriptions[type] || 'Mining software'; - } - - getMinerAlgorithms(type: string): string[] { - const algorithms: Record = { - 'xmrig': ['RandomX', 'CryptoNight'], - 'ttminer': ['Ethash', 'KawPow', 'Autolykos2'], - 'lolminer': ['Ethash', 'Beam', 'Equihash'], - 'trex': ['Ethash', 'KawPow', 'Octopus'] - }; - return algorithms[type] || []; - } - installMiner(type: string) { this.installing.set(type); this.minerService.installMiner(type).subscribe({ diff --git a/ui/src/app/websocket.service.ts b/ui/src/app/websocket.service.ts new file mode 100644 index 0000000..fbf6685 --- /dev/null +++ b/ui/src/app/websocket.service.ts @@ -0,0 +1,301 @@ +import { Injectable, signal, computed, OnDestroy, NgZone, inject } from '@angular/core'; +import { Subject, Observable, timer, Subscription, BehaviorSubject } from 'rxjs'; +import { filter, map, share, takeUntil } from 'rxjs/operators'; + +// --- Event Types --- +export type MiningEventType = + | 'miner.starting' + | 'miner.started' + | 'miner.stopping' + | 'miner.stopped' + | 'miner.stats' + | 'miner.error' + | 'miner.connected' + | 'profile.created' + | 'profile.updated' + | 'profile.deleted' + | 'pong'; + +export interface MinerStatsData { + name: string; + hashrate: number; + shares: number; + rejected: number; + uptime: number; + algorithm?: string; + diffCurrent?: number; +} + +export interface MinerEventData { + name: string; + profileId?: string; + reason?: string; + error?: string; + pool?: string; +} + +export interface MiningEvent { + type: MiningEventType; + timestamp: string; + data?: T; +} + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; + +@Injectable({ + providedIn: 'root' +}) +export class WebSocketService implements OnDestroy { + private ngZone = inject(NgZone); + + // WebSocket connection + private socket: WebSocket | null = null; + private wsUrl = 'ws://localhost:9090/api/v1/mining/ws/events'; + + // Connection state + private connectionState = signal('disconnected'); + readonly isConnected = computed(() => this.connectionState() === 'connected'); + readonly state = this.connectionState.asReadonly(); + + // Event stream + private eventsSubject = new Subject(); + private destroy$ = new Subject(); + + // Reconnection + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private baseReconnectDelay = 1000; // 1 second + private maxReconnectDelay = 30000; // 30 seconds + private reconnectSubscription?: Subscription; + private pingInterval?: ReturnType; + + // Observable streams for specific event types + readonly events$ = this.eventsSubject.asObservable().pipe(share()); + + readonly minerStats$ = this.events$.pipe( + filter((e): e is MiningEvent => e.type === 'miner.stats'), + map(e => e.data!) + ); + + readonly minerStarting$ = this.events$.pipe( + filter((e): e is MiningEvent => e.type === 'miner.starting'), + map(e => e.data!) + ); + + readonly minerStarted$ = this.events$.pipe( + filter((e): e is MiningEvent => e.type === 'miner.started'), + map(e => e.data!) + ); + + readonly minerStopping$ = this.events$.pipe( + filter((e): e is MiningEvent => e.type === 'miner.stopping'), + map(e => e.data!) + ); + + readonly minerStopped$ = this.events$.pipe( + filter((e): e is MiningEvent => e.type === 'miner.stopped'), + map(e => e.data!) + ); + + readonly minerError$ = this.events$.pipe( + filter((e): e is MiningEvent => e.type === 'miner.error'), + map(e => e.data!) + ); + + readonly minerConnected$ = this.events$.pipe( + filter((e): e is MiningEvent => e.type === 'miner.connected'), + map(e => e.data!) + ); + + constructor() { + // Auto-connect on service creation + this.connect(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.disconnect(); + } + + /** + * Connect to the WebSocket server + */ + connect(): void { + if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) { + return; // Already connected or connecting + } + + this.connectionState.set('connecting'); + console.log('[WebSocket] Connecting to', this.wsUrl); + + try { + this.socket = new WebSocket(this.wsUrl); + + this.socket.onopen = () => { + this.ngZone.run(() => { + console.log('[WebSocket] Connected'); + this.connectionState.set('connected'); + this.reconnectAttempts = 0; + + // Subscribe to all miners by default + this.send({ type: 'subscribe', miners: ['*'] }); + + // Start ping interval to keep connection alive + this.startPingInterval(); + }); + }; + + this.socket.onmessage = (event) => { + this.ngZone.run(() => { + try { + const data = JSON.parse(event.data) as MiningEvent; + this.eventsSubject.next(data); + + // Log non-stats events for debugging + if (data.type !== 'miner.stats' && data.type !== 'pong') { + console.log('[WebSocket] Event:', data.type, data.data); + } + } catch (err) { + console.error('[WebSocket] Failed to parse message:', err); + } + }); + }; + + this.socket.onclose = (event) => { + this.ngZone.run(() => { + console.log('[WebSocket] Connection closed:', event.code, event.reason); + this.stopPingInterval(); + this.connectionState.set('disconnected'); + this.socket = null; + + // Attempt reconnection unless intentionally closed + if (event.code !== 1000) { + this.scheduleReconnect(); + } + }); + }; + + this.socket.onerror = (error) => { + this.ngZone.run(() => { + console.error('[WebSocket] Error:', error); + // The onclose event will handle reconnection + }); + }; + } catch (err) { + console.error('[WebSocket] Failed to create connection:', err); + this.connectionState.set('disconnected'); + this.scheduleReconnect(); + } + } + + /** + * Disconnect from the WebSocket server + */ + disconnect(): void { + this.cancelReconnect(); + this.stopPingInterval(); + + if (this.socket) { + this.socket.close(1000, 'Client disconnecting'); + this.socket = null; + } + + this.connectionState.set('disconnected'); + } + + /** + * Send a message to the server + */ + private send(message: object): void { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)); + } + } + + /** + * Subscribe to specific miners (or '*' for all) + */ + subscribeToMiners(miners: string[]): void { + this.send({ type: 'subscribe', miners }); + } + + /** + * Schedule a reconnection with exponential backoff + */ + private scheduleReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('[WebSocket] Max reconnection attempts reached'); + return; + } + + this.cancelReconnect(); + this.connectionState.set('reconnecting'); + + // Exponential backoff with jitter + const delay = Math.min( + this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000, + this.maxReconnectDelay + ); + + this.reconnectAttempts++; + console.log(`[WebSocket] Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + + this.reconnectSubscription = timer(delay) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.connect(); + }); + } + + /** + * Cancel any pending reconnection + */ + private cancelReconnect(): void { + if (this.reconnectSubscription) { + this.reconnectSubscription.unsubscribe(); + this.reconnectSubscription = undefined; + } + } + + /** + * Start sending periodic pings to keep the connection alive + */ + private startPingInterval(): void { + this.stopPingInterval(); + this.pingInterval = setInterval(() => { + this.send({ type: 'ping' }); + }, 30000); // Every 30 seconds + } + + /** + * Stop the ping interval + */ + private stopPingInterval(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = undefined; + } + } + + /** + * Get events for a specific miner + */ + getMinerEvents(minerName: string): Observable { + return this.events$.pipe( + filter(e => { + const data = e.data as MinerEventData | MinerStatsData | undefined; + return data?.name === minerName; + }) + ); + } + + /** + * Get stats events for a specific miner + */ + getMinerStats(minerName: string): Observable { + return this.minerStats$.pipe( + filter(data => data.name === minerName) + ); + } +}