package fleet import ( "fmt" "time" "forge-mesh/internal/api/types" "forge-mesh/internal/policy" "github.com/google/uuid" ) // FleetSummary aggregates fleet stats for the dashboard API. type FleetSummary struct { Hosts []FleetHostCard `json:"hosts"` TotalHashrate float64 `json:"totalHashrate"` OnlineCount int `json:"onlineCount"` } // FleetHostCard is the dashboard-facing host shape (matches React types). type FleetHostCard struct { ID string `json:"id"` Hostname string `json:"hostname"` IP string `json:"ip"` Arch string `json:"arch"` Hashrate float64 `json:"hashrate"` Tier int `json:"tier"` TierName string `json:"tierName"` TierState string `json:"tierState"` Algo string `json:"algo"` UptimeSec int `json:"uptimeSec"` LastSeen string `json:"lastSeen"` Clearance int `json:"clearance"` Online bool `json:"online"` } // ToFleetCard converts a DB host to a dashboard card. func ToFleetCard(h *types.Host) FleetHostCard { if h == nil { return FleetHostCard{} } hps := h.HashrateHps if hps == 0 { hps = h.Hashrate } online := h.Status == "online" || h.Status == "mining" || h.Status == "paused" tier := h.CurrentTier if tier == 0 { tier = 2 } tierName := policy.TierDisplayName(h.TierType) if tierName == "" || tierName == h.TierType { tierName = "Bundled xmrig" } tierState := h.TierState if tierState == "" { switch h.Status { case "mining": tierState = "active" case "probing": tierState = "probing" case "paused": tierState = "paused" case "offline": tierState = "idle" online = false default: tierState = "idle" } } arch := h.Phenotype if arch == "" { arch = "linux/amd64" } lastSeen := "" if h.LastSeenAt != nil { lastSeen = h.LastSeenAt.UTC().Format(time.RFC3339) } return FleetHostCard{ ID: h.ID, Hostname: h.Hostname, IP: coalesceIP(h.Fingerprint), Arch: arch, Hashrate: hps, Tier: tier, TierName: tierName, TierState: tierState, Algo: "rx/0", UptimeSec: 0, LastSeen: lastSeen, Clearance: h.ClearanceLevel, Online: online, } } func coalesceIP(fp string) string { if fp == "" { return "—" } return fp } // BuildFleetSummary builds the GET /api/v1/fleet response. func (s *Store) BuildFleetSummary() (FleetSummary, error) { hosts, err := s.ListHosts() if err != nil { return FleetSummary{}, err } summary := FleetSummary{Hosts: make([]FleetHostCard, 0, len(hosts))} for i := range hosts { card := ToFleetCard(&hosts[i]) summary.Hosts = append(summary.Hosts, card) if card.Online { summary.OnlineCount++ summary.TotalHashrate += card.Hashrate } } return summary, nil } // SeedDemoHost inserts a demo host when fleet is empty. func (s *Store) SeedDemoHost() error { hosts, err := s.ListHosts() if err != nil { return err } if len(hosts) > 0 { return nil } id := uuid.NewString() now := time.Now().UTC().Format(time.RFC3339) _, err = s.db.Exec(` INSERT INTO hosts (id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps, current_tier, tier_type, tier_state, clearance_level, last_seen_at, created_at, updated_at) VALUES (?, 'forge-node-alpha', '10.0.1.12', 'linux/amd64', 'mining', 18200000, 18200000, 2, 'xmrig', 'active', 2, ?, ?, ?)`, id, now, now, now) return err } // TouchHost enrolls or refreshes a host from the register API. func (s *Store) TouchHost(hostname, fingerprint, arch string) (*types.Host, error) { phenotype := "" if arch != "" { phenotype = "linux/" + arch } hb := types.HeartbeatPayload{ Hostname: hostname, Fingerprint: fingerprint, Arch: arch, TierState: "idle", } hb.SetHashrateFields(0) host, err := s.UpsertHeartbeat(hb) if err != nil { return nil, err } if phenotype != "" { now := time.Now().UTC().Format(time.RFC3339) _, _ = s.db.Exec(`UPDATE hosts SET phenotype = ?, updated_at = ? WHERE id = ?`, phenotype, now, host.ID) return s.GetHost(host.ID) } return host, nil } // SetHostStatus updates host status. func (s *Store) SetHostStatus(id, status string) error { now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.Exec(`UPDATE hosts SET status = ?, updated_at = ? WHERE id = ?`, status, now, id) return err } // ErrNotFound indicates a missing host. var ErrNotFound = fmt.Errorf("host not found")