package db import ( "database/sql" "encoding/json" "fmt" "os" "path/filepath" "strings" "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 extra_files 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 ''`) _, _ = 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 ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_hashrate_15m REAL DEFAULT 0`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`) _, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`) 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, 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, 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, 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 } 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) UpdateAgentGPUStats(agentID string, hashrate float64, model string, active bool) error { _, err := d.Exec( `UPDATE agents SET gpu_hashrate_15m = ?, gpu_model = ?, gpu_miner_active = ? WHERE id = ?`, hashrate, model, boolToInt(active), agentID, ) return err } func (d *Database) SetAgentOffline(id string) error { _, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id) return err } // MarkAllAgentsOffline resets every agent row to offline. Called once at // server startup so rows left online by a previous crash are corrected // before any agent has had a chance to reconnect. func (d *Database) MarkAllAgentsOffline() error { _, err := d.Exec("UPDATE agents SET status = 'offline' WHERE status = 'online'") return err } // MarkStaleAgentsOffline marks agents offline when their last_seen timestamp // is older than the given staleness threshold. Returns the number of rows // updated so the caller can emit a log line when something actually changed. func (d *Database) MarkStaleAgentsOffline(olderThan time.Duration) (int, error) { cutoff := time.Now().Add(-olderThan) res, err := d.Exec( "UPDATE agents SET status = 'offline' WHERE status = 'online' AND last_seen < ?", cutoff, ) if err != nil { return 0, err } n, _ := res.RowsAffected() return int(n), nil } func (d *Database) DeleteAgent(id string) error { _, err := d.Exec("DELETE FROM agents WHERE id = ?", id) return err } // FindAgentByMAC returns the agent ID for an agent whose MAC address matches. // Returns ("", nil) when no match is found. func (d *Database) FindAgentByMAC(mac string) (string, error) { if mac == "" { return "", nil } var id string err := d.QueryRow("SELECT id FROM agents WHERE mac_address = ? LIMIT 1", mac).Scan(&id) if err == sql.ErrNoRows { return "", nil } return id, 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, gpuHashrate float64) error { _, err := d.Exec( "INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)", agentID, hashrate, gpuHashrate, time.Now(), ) return err } func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) { query := `SELECT id, agent_id, hashrate, gpu_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.GPUHashrate, &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, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned` func encodeBuildExtraFiles(files []models.BuildExtraFile) string { if len(files) == 0 { return "[]" } b, err := json.Marshal(files) if err != nil { return "[]" } return string(b) } func decodeBuildExtraFiles(raw string) []models.BuildExtraFile { raw = strings.TrimSpace(raw) if raw == "" || raw == "[]" || raw == "null" { return nil } var files []models.BuildExtraFile if err := json.Unmarshal([]byte(raw), &files); err != nil { return nil } return files } func scanBuild(row interface { Scan(...any) error }) (*models.BuildRecord, error) { b := &models.BuildRecord{} var pinnedInt int var extraFilesRaw string err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize, &b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt) b.Pinned = pinnedInt == 1 b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw) 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, extra_files, 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, encodeBuildExtraFiles(b.ExtraFiles), 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. Returns an error when id is // non-empty but no build row matches (avoids leaving all builds 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 } res, err := d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id) if err != nil { return err } n, err := res.RowsAffected() if err != nil { return err } if n == 0 { return fmt.Errorf("build not found: %s", id) } return nil } 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"` TotalGPUHashrate float64 `json:"total_gpu_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(gpu_hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalGPUHashrate) 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 }