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:
parent
dc532d239e
commit
757526e60e
13 changed files with 2401 additions and 168 deletions
|
|
@ -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
187
cmd/mining/cmd/simulate.go
Normal 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"
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
331
pkg/mining/events.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
447
pkg/mining/simulated_miner.go
Normal file
447
pkg/mining/simulated_miner.go
Normal 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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
301
ui/src/app/websocket.service.ts
Normal file
301
ui/src/app/websocket.service.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue