feat: Add WebSocket events, simulation mode, and redesigned Miners page

WebSocket Real-Time Events:
- Add EventHub for broadcasting miner events to connected clients
- New event types: miner.starting/started/stopping/stopped/stats/error
- WebSocket endpoint at /ws/events with auto-reconnect support
- Angular WebSocketService with RxJS event streams and fallback to polling

Simulation Mode (miner-ctrl simulate):
- SimulatedMiner generates realistic hashrate data for UI development
- Supports presets: cpu-low, cpu-medium, cpu-high, gpu-ethash, gpu-kawpow
- Features: variance, sine-wave fluctuation, 30s ramp-up, 98% share rate
- XMRig-compatible stats format for full UI compatibility
- NewManagerForSimulation() skips autostart of real miners

Miners Page Redesign:
- Featured cards for installed/recommended miners with gradient styling
- "Installed" (green) and "Recommended" (gold) ribbon badges
- Placeholder cards for 8 planned miners with "Coming Soon" badges
- Algorithm badges, GitHub links, and license info for each miner
- Planned miners: T-Rex, lolMiner, Rigel, BzMiner, SRBMiner, TeamRedMiner, GMiner, NBMiner

Chart Improvements:
- Hybrid data approach: live in-memory data while active, database historical when inactive
- Smoother transitions between data sources

Documentation:
- Updated DEVELOPMENT.md with simulation mode usage
- Updated ARCHITECTURE.md with WebSocket, simulation, and supported miners table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
snider 2025-12-31 07:11:41 +00:00
parent dc532d239e
commit 757526e60e
13 changed files with 2401 additions and 168 deletions

View file

@ -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()
}

187
cmd/mining/cmd/simulate.go Normal file
View file

@ -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"
}

View file

@ -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 |

View file

@ -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:

331
pkg/mining/events.go Normal file
View file

@ -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()
}

View file

@ -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,
})
}
}

View file

@ -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)
}

View file

@ -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,
},
}

View file

@ -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<string, { timestamp: string; hashrate: number }[]>();
// 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());

View file

@ -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 = `<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke
@for (miner of runningMiners(); track miner.name) {
<div class="dropdown-item miner-item"
[class.active]="selectedMinerName() === miner.name"
[class.stopping]="isLoading('stop-' + miner.name)"
(contextmenu)="openContextMenu($event, miner.name)">
<button class="miner-select" (click)="selectMiner(miner.name)">
<div class="miner-status-dot online"></div>
<span class="miner-name">{{ miner.name }}</span>
<span class="miner-hashrate">{{ formatHashrate(getHashrate(miner)) }}</span>
</button>
<div class="miner-actions">
<button
class="action-btn stop"
[class.loading]="isLoading('stop-' + miner.name)"
[disabled]="isLoading('stop-' + miner.name)"
title="Stop miner"
(click)="stopMiner($event, miner.name)">
@if (isLoading('stop-' + miner.name)) {
<div class="miner-actions" [class.show]="isLoading('stop-' + miner.name)">
@if (isLoading('stop-' + miner.name)) {
<div class="stopping-indicator">
<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
} @else {
<span class="stopping-text">Stopping</span>
</div>
<button
class="cancel-btn"
title="Cancel"
(click)="cancelAction($event, 'stop-' + miner.name)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
} @else {
<button
class="action-btn stop"
title="Stop miner"
(click)="stopMiner($event, miner.name)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
}
</button>
<button
class="action-btn edit"
title="Edit configuration"
(click)="editMiner($event, miner.name)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
</button>
<button
class="action-btn edit"
title="Edit configuration"
(click)="editMiner($event, miner.name)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
}
</div>
</div>
}
@ -110,24 +122,36 @@ const SPINNER_SVG = `<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke
<div class="start-section">
<span class="section-label">Start Worker</span>
@for (profile of profiles(); track profile.id) {
<button
class="dropdown-item start-item"
[class.loading]="isLoading('start-' + profile.id)"
[disabled]="isLoading('start-' + profile.id)"
(click)="startProfile(profile.id, profile.name)">
<div class="start-item-wrapper" [class.loading]="isLoading('start-' + profile.id)">
<button
class="dropdown-item start-item"
[class.loading]="isLoading('start-' + profile.id)"
[disabled]="isLoading('start-' + profile.id)"
(click)="startProfile(profile.id, profile.name)">
@if (isLoading('start-' + profile.id)) {
<svg class="item-icon spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
} @else {
<svg class="item-icon play" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
}
<span>{{ profile.name }}</span>
<span class="profile-type">{{ profile.minerType }}</span>
</button>
@if (isLoading('start-' + profile.id)) {
<svg class="item-icon spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
} @else {
<svg class="item-icon play" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<button
class="cancel-btn"
title="Cancel"
(click)="cancelAction($event, 'start-' + profile.id)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
}
<span>{{ profile.name }}</span>
<span class="profile-type">{{ profile.minerType }}</span>
</button>
</div>
}
</div>
}
@ -363,15 +387,41 @@ const SPINNER_SVG = `<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke
.miner-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.15s ease;
}
.miner-item:hover .miner-actions {
.miner-item:hover .miner-actions,
.miner-actions.show {
opacity: 1;
}
/* Stopping indicator */
.stopping-indicator {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--color-accent-400);
}
.stopping-indicator .spinner {
width: 14px;
height: 14px;
}
.stopping-text {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.miner-item.stopping {
background: rgb(0 212 255 / 0.05);
}
.action-btn {
display: flex;
align-items: center;
@ -555,10 +605,63 @@ const SPINNER_SVG = `<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke
.context-menu-item.loading .spinner {
color: var(--color-accent-400);
}
/* Start item wrapper for cancel button positioning */
.start-item-wrapper {
position: relative;
display: flex;
align-items: center;
}
.start-item-wrapper .start-item {
flex: 1;
}
/* Cancel button - appears on hover over loading items */
.cancel-btn {
position: absolute;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--color-danger-500);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
opacity: 0;
transform: scale(0.8);
transition: all 0.15s ease;
z-index: 10;
}
.cancel-btn svg {
width: 12px;
height: 12px;
}
.start-item-wrapper:hover .cancel-btn,
.miner-item:hover .cancel-btn {
opacity: 1;
transform: scale(1);
}
.cancel-btn:hover {
background: var(--color-danger-400);
transform: scale(1.1);
}
.cancel-btn:active {
transform: scale(0.95);
}
`]
})
export class MinerSwitcherComponent {
export class MinerSwitcherComponent implements OnDestroy {
private minerService = inject(MinerService);
private ws = inject(WebSocketService);
private wsSubscriptions: Subscription[] = [];
// Output for edit action (navigate to profiles page)
editProfile = output<string>();
@ -573,6 +676,76 @@ export class MinerSwitcherComponent {
// Track loading states for actions (e.g., "stop-minerName", "start-profileId")
private loadingActions = signal<Set<string>>(new Set());
// Track pending start actions to match profile IDs with miner names
private pendingStarts = new Map<string, string>(); // 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();
}

View file

@ -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<SystemState>({
@ -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();
}
});

View file

@ -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';
</div>
</div>
<div class="miners-grid">
@for (miner of availableMiners(); track miner.type) {
<div class="miner-card" [class.installed]="miner.installed">
<div class="miner-icon">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
</svg>
</div>
<div class="miner-info">
<h3>{{ miner.name }}</h3>
<p class="miner-description">{{ miner.description }}</p>
<div class="miner-meta">
@if (miner.version) {
<span class="meta-badge">v{{ miner.version }}</span>
}
@if (miner.algorithms.length > 0) {
<span class="meta-badge algo">{{ miner.algorithms.join(', ') }}</span>
}
</div>
</div>
<div class="miner-actions">
@if (!miner.installed) {
<button
class="btn btn-primary"
[disabled]="installing() === miner.type"
(click)="installMiner(miner.type)">
@if (installing() === miner.type) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Installing...
} @else {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Install
}
</button>
} @else {
<div class="installed-badge">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- Installed/Recommended Miners -->
@if (featuredMiners().length > 0) {
<div class="section-header">
<h3>Installed & Recommended</h3>
</div>
<div class="featured-miners-grid">
@for (miner of featuredMiners(); track miner.type) {
<div class="miner-card featured" [class.installed]="miner.installed">
<div class="featured-ribbon" [class.recommended]="miner.recommended && !miner.installed">
@if (miner.installed) {
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
Installed
</div>
<button class="btn btn-outline" (click)="uninstallMiner(miner.type)">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
} @else {
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
Uninstall
</button>
}
Recommended
}
</div>
<div class="featured-header">
<div class="miner-icon large">
@if (miner.type === 'xmrig') {
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
} @else {
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
</svg>
}
</div>
<div class="featured-title">
<h3>{{ miner.name }}</h3>
@if (miner.version) {
<span class="version-badge">v{{ miner.version }}</span>
}
</div>
</div>
<p class="miner-description">{{ miner.description }}</p>
<div class="miner-meta">
@for (algo of miner.algorithms; track algo) {
<span class="meta-badge algo">{{ algo }}</span>
}
</div>
<div class="featured-footer">
<div class="miner-links">
@if (miner.homepage) {
<a [href]="miner.homepage" target="_blank" class="link-badge">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
Website
</a>
}
@if (miner.license) {
<span class="license-badge">{{ miner.license }}</span>
}
</div>
<div class="miner-actions">
@if (!miner.installed) {
<button
class="btn btn-primary"
[disabled]="installing() === miner.type"
(click)="installMiner(miner.type)">
@if (installing() === miner.type) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Installing...
} @else {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Install
}
</button>
} @else {
<button class="btn btn-outline-danger" (click)="uninstallMiner(miner.type)">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Uninstall
</button>
}
</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
<!-- Other Available Miners -->
@if (otherMiners().length > 0) {
<div class="section-header">
<h3>Other Available Miners</h3>
</div>
<div class="miners-grid">
@for (miner of otherMiners(); track miner.type) {
<div class="miner-card compact" [class.placeholder]="miner.placeholder">
<div class="miner-icon">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
</svg>
</div>
<div class="miner-info">
<div class="miner-name-row">
<h3>{{ miner.name }}</h3>
@if (miner.placeholder) {
<span class="coming-soon-badge">Coming Soon</span>
}
</div>
<p class="miner-description">{{ miner.description }}</p>
<div class="miner-meta">
@for (algo of miner.algorithms.slice(0, 3); track algo) {
<span class="meta-badge algo">{{ algo }}</span>
}
@if (miner.algorithms.length > 3) {
<span class="meta-badge">+{{ miner.algorithms.length - 3 }}</span>
}
@if (miner.homepage) {
<a [href]="miner.homepage" target="_blank" class="meta-badge link">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
GitHub
</a>
}
</div>
</div>
<div class="miner-actions">
@if (miner.placeholder) {
<span class="placeholder-badge">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Planned
</span>
} @else {
<button
class="btn btn-secondary"
[disabled]="installing() === miner.type"
(click)="installMiner(miner.type)">
@if (installing() === miner.type) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
} @else {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
}
Install
</button>
}
</div>
</div>
}
</div>
}
<!-- System Info -->
@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<string | null>(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<string, Partial<MinerInfo>> = {
// 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<MinerInfo[]>(() => {
// 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<MinerInfo[]>(() => {
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<MinerInfo[]>(() => {
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<string, string> = {
'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<string, string[]> = {
'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({

View file

@ -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<T = unknown> {
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<ConnectionState>('disconnected');
readonly isConnected = computed(() => this.connectionState() === 'connected');
readonly state = this.connectionState.asReadonly();
// Event stream
private eventsSubject = new Subject<MiningEvent>();
private destroy$ = new Subject<void>();
// Reconnection
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private baseReconnectDelay = 1000; // 1 second
private maxReconnectDelay = 30000; // 30 seconds
private reconnectSubscription?: Subscription;
private pingInterval?: ReturnType<typeof setInterval>;
// Observable streams for specific event types
readonly events$ = this.eventsSubject.asObservable().pipe(share());
readonly minerStats$ = this.events$.pipe(
filter((e): e is MiningEvent<MinerStatsData> => e.type === 'miner.stats'),
map(e => e.data!)
);
readonly minerStarting$ = this.events$.pipe(
filter((e): e is MiningEvent<MinerEventData> => e.type === 'miner.starting'),
map(e => e.data!)
);
readonly minerStarted$ = this.events$.pipe(
filter((e): e is MiningEvent<MinerEventData> => e.type === 'miner.started'),
map(e => e.data!)
);
readonly minerStopping$ = this.events$.pipe(
filter((e): e is MiningEvent<MinerEventData> => e.type === 'miner.stopping'),
map(e => e.data!)
);
readonly minerStopped$ = this.events$.pipe(
filter((e): e is MiningEvent<MinerEventData> => e.type === 'miner.stopped'),
map(e => e.data!)
);
readonly minerError$ = this.events$.pipe(
filter((e): e is MiningEvent<MinerEventData> => e.type === 'miner.error'),
map(e => e.data!)
);
readonly minerConnected$ = this.events$.pipe(
filter((e): e is MiningEvent<MinerEventData> => 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<MiningEvent> {
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<MinerStatsData> {
return this.minerStats$.pipe(
filter(data => data.name === minerName)
);
}
}