package api import ( "context" "encoding/json" "net/http" "sort" "time" ) // repoInfo is the shape the dashboard consumes. It carries the nodeID // the repo lives on, so the dashboard can pick the right agent when // building a deploy. type repoInfo struct { Name string `json:"name"` Node string `json:"node"` Path string `json:"path"` } // nodeRepoList is the reply shape from the agent's "list_repos" RPC. type nodeRepoList struct { Repos []repoInfo `json:"repos"` } // handleListRepos queries every connected agent for its repo list and // merges the results. func (s *Server) handleListRepos(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } s.agents.mu.RLock() nodes := make([]string, 0, len(s.agents.conns)) for n, c := range s.agents.conns { if c { nodes = append(nodes, n) } } s.agents.mu.RUnlock() sort.Strings(nodes) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() merged := make([]repoInfo, 0) for _, node := range nodes { raw, err := s.hub.CallAgent(ctx, node, "list_repos", map[string]any{}, 0) if err != nil { continue // agent flaked; skip } var reply nodeRepoList if err := json.Unmarshal(raw, &reply); err != nil { continue } for _, repo := range reply.Repos { repo.Node = node merged = append(merged, repo) } } // Stable order: by node then name. sort.Slice(merged, func(i, j int) bool { if merged[i].Node != merged[j].Node { return merged[i].Node < merged[j].Node } return merged[i].Name < merged[j].Name }) writeJSON(w, http.StatusOK, merged) } // handleListBranches asks the connected agent that owns `repo` for its // branches. The repo's node is implied by the prefix path; for now we // try both connected agents and merge. func (s *Server) handleListBranches(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } repo := r.URL.Query().Get("repo") if repo == "" { writeErr(w, http.StatusBadRequest, "repo required") return } s.agents.mu.RLock() nodes := make([]string, 0, len(s.agents.conns)) for n, c := range s.agents.conns { if c { nodes = append(nodes, n) } } s.agents.mu.RUnlock() ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() branches := make([]string, 0) for _, node := range nodes { raw, err := s.hub.CallAgent(ctx, node, "list_branches", map[string]any{"repo": repo}, 0) if err != nil { continue } var reply struct { OK bool `json:"ok"` Branches []string `json:"branches"` } if err := json.Unmarshal(raw, &reply); err != nil || !reply.OK { continue } branches = append(branches, reply.Branches...) } sort.Strings(branches) // dedupe dedup := make([]string, 0, len(branches)) seen := map[string]bool{} for _, b := range branches { if !seen[b] { dedup = append(dedup, b) seen[b] = true } } writeJSON(w, http.StatusOK, dedup) } // handleListAgents returns which agents are currently connected. func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } s.agents.mu.RLock() defer s.agents.mu.RUnlock() out := make([]map[string]string, 0, len(s.agents.conns)) for n, c := range s.agents.conns { if c { out = append(out, map[string]string{"node": n}) } } sort.Slice(out, func(i, j int) bool { return out[i]["node"] < out[j]["node"] }) writeJSON(w, http.StatusOK, out) } // handleListRoutes proxies the list_routes RPC to the gateway agent. // The dashboard uses this to show the live _url map from the // currently-checked-out branch's config.php. func (s *Server) handleListRoutes(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() raw, err := s.hub.CallAgent(ctx, "gateway", "list_routes", map[string]any{}, 0) if err != nil { writeErr(w, http.StatusBadGateway, err.Error()) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(raw) }