Add Build Manager with pin-to-dropper, Crucible multi-node terminal with SSH probe/wake, Command Deck chart balance and pretty stats, AetherForge logo and sacred geometry UI, Field Guide refresh, and LAUNCH.bat Cloudflare MSI + token service install flow.
399 lines
13 KiB
Go
399 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"crypto-miner-server/internal/models"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type Database struct {
|
|
*sql.DB
|
|
}
|
|
|
|
func New(dataDir string) (*Database, error) {
|
|
dbPath := filepath.Join(dataDir, "miner.db")
|
|
|
|
// Ensure directory exists
|
|
os.MkdirAll(filepath.Dir(dbPath), 0755)
|
|
|
|
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
d := &Database{db}
|
|
if err := d.migrate(); err != nil {
|
|
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
func (d *Database) migrate() error {
|
|
migrations := []string{
|
|
`CREATE TABLE IF NOT EXISTS agents (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
wallet TEXT NOT NULL DEFAULT '',
|
|
ip TEXT NOT NULL DEFAULT '',
|
|
version TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'offline',
|
|
cpu_cores INTEGER NOT NULL DEFAULT 0,
|
|
memory_gb INTEGER NOT NULL DEFAULT 0,
|
|
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
hashrate_15s REAL NOT NULL DEFAULT 0,
|
|
hashrate_1m REAL NOT NULL DEFAULT 0,
|
|
hashrate_15m REAL NOT NULL DEFAULT 0,
|
|
shares_total INTEGER NOT NULL DEFAULT 0,
|
|
shares_good INTEGER NOT NULL DEFAULT 0,
|
|
shares_bad INTEGER NOT NULL DEFAULT 0,
|
|
cpu_usage_pct REAL NOT NULL DEFAULT 0,
|
|
memory_usage_pct REAL NOT NULL DEFAULT 0,
|
|
uptime_seconds INTEGER NOT NULL DEFAULT 0
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS shares (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent_id TEXT NOT NULL,
|
|
job_id TEXT NOT NULL,
|
|
difficulty INTEGER NOT NULL DEFAULT 0,
|
|
accepted INTEGER NOT NULL DEFAULT 0,
|
|
hash TEXT NOT NULL DEFAULT '',
|
|
nonce TEXT NOT NULL DEFAULT '',
|
|
error TEXT NOT NULL DEFAULT '',
|
|
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS hashrate_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent_id TEXT NOT NULL,
|
|
hashrate REAL NOT NULL DEFAULT 0,
|
|
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
height INTEGER NOT NULL DEFAULT 0,
|
|
difficulty INTEGER NOT NULL DEFAULT 0,
|
|
block_template TEXT NOT NULL DEFAULT '',
|
|
seed_hash TEXT NOT NULL DEFAULT '',
|
|
target TEXT NOT NULL DEFAULT '',
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS builds (
|
|
id TEXT PRIMARY KEY,
|
|
worker_name TEXT NOT NULL,
|
|
server_url TEXT NOT NULL,
|
|
wallet TEXT NOT NULL,
|
|
threads INTEGER NOT NULL DEFAULT 0,
|
|
file_size INTEGER NOT NULL DEFAULT 0,
|
|
file_path TEXT NOT NULL DEFAULT '',
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
pool_host TEXT NOT NULL DEFAULT '',
|
|
pool_port INTEGER NOT NULL DEFAULT 0,
|
|
pool_tls INTEGER NOT NULL DEFAULT 0,
|
|
pool_pass TEXT NOT NULL DEFAULT ''
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_shares_agent ON shares(agent_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_shares_timestamp ON shares(timestamp)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_hashrate_agent ON hashrate_samples(agent_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_hashrate_timestamp ON hashrate_samples(timestamp)`,
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
if _, err := d.Exec(m); err != nil {
|
|
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
|
|
}
|
|
}
|
|
|
|
// Best-effort schema upgrades for existing databases.
|
|
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
|
|
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
|
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
|
|
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
|
|
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
|
|
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`)
|
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
|
_, _ = 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 ''`)
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name,
|
|
wallet = excluded.wallet,
|
|
ip = excluded.ip,
|
|
version = excluded.version,
|
|
status = excluded.status,
|
|
cpu_cores = excluded.cpu_cores,
|
|
memory_gb = excluded.memory_gb,
|
|
last_seen = excluded.last_seen,
|
|
platform = excluded.platform,
|
|
arch = excluded.arch,
|
|
os_version = excluded.os_version`
|
|
_, 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)
|
|
return err
|
|
}
|
|
|
|
func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate15m float64, sharesTotal, sharesGood, sharesBad int, cpuPct, memPct float64, uptime int) error {
|
|
query := `UPDATE agents SET
|
|
hashrate_15s = ?, hashrate_1m = ?, hashrate_15m = ?,
|
|
shares_total = ?, shares_good = ?, shares_bad = ?,
|
|
cpu_usage_pct = ?, memory_usage_pct = ?, uptime_seconds = ?,
|
|
last_seen = CURRENT_TIMESTAMP, status = 'online'
|
|
WHERE id = ?`
|
|
_, err := d.Exec(query, hashrate15s, hashrate1m, hashrate15m, sharesTotal, sharesGood, sharesBad, cpuPct, memPct, uptime, id)
|
|
return err
|
|
}
|
|
|
|
func (d *Database) SetAgentOffline(id string) error {
|
|
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
func (d *Database) GetAgent(id string) (*models.Agent, error) {
|
|
query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?`
|
|
return d.scanAgent(d.QueryRow(query, id))
|
|
}
|
|
|
|
func (d *Database) ListAgents() ([]*models.Agent, error) {
|
|
query := `SELECT ` + agentSelectCols + ` FROM agents ORDER BY last_seen DESC`
|
|
rows, err := d.Query(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var agents []*models.Agent
|
|
for rows.Next() {
|
|
a, err := d.scanAgent(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agents = append(agents, a)
|
|
}
|
|
return agents, nil
|
|
}
|
|
|
|
// Share operations
|
|
|
|
func (d *Database) InsertShare(s *models.Share) (int64, error) {
|
|
query := `INSERT INTO shares (agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
res, err := d.Exec(query, s.AgentID, s.JobID, s.Difficulty, boolToInt(s.Accepted), s.Hash, s.Nonce, s.Error, s.Timestamp)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
func (d *Database) UpdateShareResult(id int64, accepted bool, errMsg string) error {
|
|
query := `UPDATE shares SET accepted = ?, error = ? WHERE id = ?`
|
|
_, err := d.Exec(query, boolToInt(accepted), errMsg, id)
|
|
return err
|
|
}
|
|
|
|
func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
|
|
query := `SELECT id, agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp FROM shares ORDER BY timestamp DESC LIMIT ?`
|
|
rows, err := d.Query(query, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var shares []*models.Share
|
|
for rows.Next() {
|
|
s := &models.Share{}
|
|
var accepted int
|
|
if err := rows.Scan(&s.ID, &s.AgentID, &s.JobID, &s.Difficulty, &accepted, &s.Hash, &s.Nonce, &s.Error, &s.Timestamp); err != nil {
|
|
return nil, err
|
|
}
|
|
s.Accepted = accepted == 1
|
|
shares = append(shares, s)
|
|
}
|
|
return shares, nil
|
|
}
|
|
|
|
// Hashrate operations
|
|
|
|
func (d *Database) InsertHashrateSample(agentID string, hashrate float64) error {
|
|
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)", agentID, hashrate, time.Now())
|
|
return err
|
|
}
|
|
|
|
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
|
|
query := `SELECT id, agent_id, hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
|
|
rows, err := d.Query(query, agentID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var samples []*models.HashrateSample
|
|
for rows.Next() {
|
|
s := &models.HashrateSample{}
|
|
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.Timestamp); err != nil {
|
|
return nil, err
|
|
}
|
|
samples = append(samples, s)
|
|
}
|
|
return samples, nil
|
|
}
|
|
|
|
// Build operations
|
|
|
|
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
|
|
|
func scanBuild(row interface {
|
|
Scan(...any) error
|
|
}) (*models.BuildRecord, error) {
|
|
b := &models.BuildRecord{}
|
|
var pinnedInt int
|
|
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
|
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
|
b.Pinned = pinnedInt == 1
|
|
return b, err
|
|
}
|
|
|
|
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
|
_, err := d.Exec(`INSERT INTO builds
|
|
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
|
b.FilePath, b.FileName, b.DownloadURL, b.Platform, b.CreatedAt,
|
|
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
|
return err
|
|
}
|
|
|
|
func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
|
|
return scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE id = ?`, id))
|
|
}
|
|
|
|
// GetLatestBuildForPlatform returns the pinned build for the given platform
|
|
// (or any platform when empty), falling back to the most-recently-created build.
|
|
func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildRecord, error) {
|
|
// 1. Pinned build for this platform (exact match)
|
|
if platform != "" && platform != "any" {
|
|
b, err := scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE pinned = 1 AND platform = ? LIMIT 1`, platform))
|
|
if err == nil {
|
|
return b, nil
|
|
}
|
|
}
|
|
// 2. Any pinned build (universal or first pinned regardless of platform)
|
|
b, err := scanBuild(d.QueryRow(`SELECT ` + buildSelectCols + ` FROM builds WHERE pinned = 1 ORDER BY created_at DESC LIMIT 1`))
|
|
if err == nil {
|
|
return b, nil
|
|
}
|
|
// 3. Latest by creation time, filtered by platform when given
|
|
if platform == "" || platform == "any" {
|
|
b, err = scanBuild(d.QueryRow(`SELECT ` + buildSelectCols + ` FROM builds ORDER BY created_at DESC LIMIT 1`))
|
|
} else {
|
|
b, err = scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE platform = ? ORDER BY created_at DESC LIMIT 1`, platform))
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// SetPinnedBuild unpins all builds then pins the one with the given id.
|
|
// If id is empty, all builds are unpinned.
|
|
func (d *Database) SetPinnedBuild(id string) error {
|
|
_, err := d.Exec(`UPDATE builds SET pinned = 0`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
_, err = d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
|
|
return err
|
|
}
|
|
|
|
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
|
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var builds []*models.BuildRecord
|
|
for rows.Next() {
|
|
b, err := scanBuild(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
builds = append(builds, b)
|
|
}
|
|
return builds, nil
|
|
}
|
|
|
|
// Stats
|
|
|
|
type FleetStats struct {
|
|
TotalAgents int `json:"total_agents"`
|
|
OnlineAgents int `json:"online_agents"`
|
|
TotalHashrate float64 `json:"total_hashrate"`
|
|
TotalShares int `json:"total_shares"`
|
|
AcceptedShares int `json:"accepted_shares"`
|
|
RejectedShares int `json:"rejected_shares"`
|
|
AcceptRate float64 `json:"accept_rate"`
|
|
}
|
|
|
|
func (d *Database) GetFleetStats() (*FleetStats, error) {
|
|
stats := &FleetStats{}
|
|
|
|
err := d.QueryRow("SELECT COUNT(*) FROM agents").Scan(&stats.TotalAgents)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = d.QueryRow("SELECT COUNT(*) FROM agents WHERE status = 'online'").Scan(&stats.OnlineAgents)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = d.QueryRow("SELECT COALESCE(SUM(hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalHashrate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = d.QueryRow("SELECT COALESCE(SUM(shares_good), 0) FROM agents").Scan(&stats.AcceptedShares)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = d.QueryRow("SELECT COALESCE(SUM(shares_bad), 0) FROM agents").Scan(&stats.RejectedShares)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if stats.TotalShares > 0 {
|
|
stats.AcceptRate = float64(stats.AcceptedShares) / float64(stats.TotalShares) * 100
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|