// Package api implements the HTTP REST API for the GoClaw Gateway. package api import ( "context" "encoding/json" "fmt" "log" "net/http" "strconv" "time" "git.softuniq.eu/UniqAI/GoClaw/gateway/config" "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db" dockerclient "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/docker" "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/llm" "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/orchestrator" "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/tools" ) // Handler holds all dependencies for HTTP handlers. type Handler struct { cfg *config.Config llm *llm.Client orch *orchestrator.Orchestrator db *db.DB docker *dockerclient.DockerClient } func NewHandler(cfg *config.Config, llmClient *llm.Client, orch *orchestrator.Orchestrator, database *db.DB) *Handler { return &Handler{ cfg: cfg, llm: llmClient, orch: orch, db: database, docker: dockerclient.NewDockerClient(), } } // ─── Health ─────────────────────────────────────────────────────────────────── // GET /health func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() ollamaOK, latency, _ := h.llm.Health(ctx) respond(w, http.StatusOK, map[string]any{ "status": "ok", "service": "goclaw-gateway", "version": "1.0.0", "timestamp": time.Now().UTC().Format(time.RFC3339), "ollama": map[string]any{ "connected": ollamaOK, "latencyMs": latency, }, }) } // ─── Orchestrator ───────────────────────────────────────────────────────────── // POST /api/orchestrator/chat func (h *Handler) OrchestratorChat(w http.ResponseWriter, r *http.Request) { var req struct { Messages []orchestrator.Message `json:"messages"` Model string `json:"model,omitempty"` MaxIter int `json:"maxIter,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } if len(req.Messages) == 0 { respondError(w, http.StatusBadRequest, "messages array is required") return } log.Printf("[API] POST /api/orchestrator/chat — messages=%d model=%q", len(req.Messages), req.Model) ctx, cancel := context.WithTimeout(r.Context(), time.Duration(h.cfg.RequestTimeoutSecs)*time.Second) defer cancel() result := h.orch.Chat(ctx, req.Messages, req.Model, req.MaxIter) respond(w, http.StatusOK, result) } // GET /api/orchestrator/config func (h *Handler) OrchestratorConfig(w http.ResponseWriter, r *http.Request) { cfg := h.orch.GetConfig() respond(w, http.StatusOK, map[string]any{ "id": cfg.ID, "name": cfg.Name, "model": cfg.Model, "temperature": cfg.Temperature, "maxTokens": cfg.MaxTokens, "allowedTools": cfg.AllowedTools, "systemPromptPreview": truncate(cfg.SystemPrompt, 200), }) } // ─── Agents ─────────────────────────────────────────────────────────────────── // GET /api/agents func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { if h.db == nil { respond(w, http.StatusOK, map[string]any{"agents": []any{}, "note": "DB not connected"}) return } agents, err := h.db.ListAgents() if err != nil { respondError(w, http.StatusInternalServerError, "failed to list agents: "+err.Error()) return } respond(w, http.StatusOK, map[string]any{"agents": agents, "count": len(agents)}) } // GET /api/agents/{id} func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { respondError(w, http.StatusBadRequest, "invalid agent id") return } if h.db == nil { respondError(w, http.StatusServiceUnavailable, "DB not connected") return } agent, err := h.db.GetAgentByID(id) if err != nil { respondError(w, http.StatusNotFound, "agent not found") return } respond(w, http.StatusOK, agent) } // ─── Models ─────────────────────────────────────────────────────────────────── // GET /api/models func (h *Handler) ListModels(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() models, err := h.llm.ListModels(ctx) if err != nil { respondError(w, http.StatusBadGateway, "failed to fetch models: "+err.Error()) return } respond(w, http.StatusOK, models) } // ─── Tools ──────────────────────────────────────────────────────────────────── // GET /api/tools func (h *Handler) ListTools(w http.ResponseWriter, r *http.Request) { toolDefs := tools.OrchestratorTools() respond(w, http.StatusOK, map[string]any{ "tools": toolDefs, "count": len(toolDefs), }) } // POST /api/tools/execute func (h *Handler) ExecuteTool(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } if req.Name == "" { respondError(w, http.StatusBadRequest, "tool name is required") return } argsJSON, _ := json.Marshal(req.Arguments) executor := tools.NewExecutor("/", nil) result := executor.Execute(r.Context(), req.Name, string(argsJSON)) respond(w, http.StatusOK, map[string]any{"result": result}) } // ─── Nodes ──────────────────────────────────────────────────────────────────── // NodeInfo is the unified node response sent to the frontend. type NodeInfo struct { ID string `json:"id"` Hostname string `json:"hostname"` Role string `json:"role"` Status string `json:"status"` Availability string `json:"availability"` IP string `json:"ip"` OS string `json:"os"` Arch string `json:"arch"` CPUCores int `json:"cpuCores"` MemTotalMB int64 `json:"memTotalMB"` DockerVersion string `json:"dockerVersion"` IsLeader bool `json:"isLeader"` ManagerAddr string `json:"managerAddr,omitempty"` Labels map[string]string `json:"labels"` UpdatedAt string `json:"updatedAt"` } // ContainerInfo is a slim container summary per node. type ContainerInfo struct { ID string `json:"id"` Name string `json:"name"` Image string `json:"image"` State string `json:"state"` Status string `json:"status"` } // GET /api/nodes func (h *Handler) ListNodes(w http.ResponseWriter, r *http.Request) { // Check if Swarm is active swarmActive := h.docker.IsSwarmActive() if swarmActive { // Return real Swarm nodes nodes, err := h.docker.ListNodes() if err != nil { log.Printf("[API] ListNodes swarm error: %v — falling back to local info", err) h.listLocalNode(w, r) return } result := make([]NodeInfo, 0, len(nodes)) for _, n := range nodes { info := NodeInfo{ ID: n.ID[:12], Hostname: n.Description.Hostname, Role: n.Spec.Role, Status: n.Status.State, Availability: n.Spec.Availability, IP: n.Status.Addr, OS: n.Description.Platform.OS, Arch: n.Description.Platform.Architecture, CPUCores: int(n.Description.Resources.NanoCPUs / 1e9), MemTotalMB: n.Description.Resources.MemoryBytes / (1024 * 1024), DockerVersion: n.Description.Engine.EngineVersion, Labels: n.Spec.Labels, UpdatedAt: n.UpdatedAt.UTC().Format(time.RFC3339), } if n.ManagerStatus != nil { info.IsLeader = n.ManagerStatus.Leader info.ManagerAddr = n.ManagerStatus.Addr } if info.Labels == nil { info.Labels = map[string]string{} } result = append(result, info) } swarmInfo, _ := h.docker.GetSwarmInfo() managers, totalNodes := 0, len(result) if swarmInfo != nil { managers = swarmInfo.Swarm.Managers totalNodes = swarmInfo.Swarm.Nodes } respond(w, http.StatusOK, map[string]any{ "nodes": result, "count": len(result), "swarmActive": true, "managers": managers, "totalNodes": totalNodes, "fetchedAt": time.Now().UTC().Format(time.RFC3339), }) return } // Swarm not active — return local Docker host info h.listLocalNode(w, r) } // listLocalNode returns info about the current Docker host as a single "node". func (h *Handler) listLocalNode(w http.ResponseWriter, r *http.Request) { info, err := h.docker.GetSwarmInfo() hostname := "localhost" if err == nil && info != nil { _ = info // use for future enrichment } // Get containers running on this host containers, _ := h.docker.ListContainers() containerInfos := make([]ContainerInfo, 0, len(containers)) for _, c := range containers { name := c.ID[:12] if len(c.Names) > 0 { name = c.Names[0] if len(name) > 0 && name[0] == '/' { name = name[1:] } } containerInfos = append(containerInfos, ContainerInfo{ ID: c.ID[:12], Name: name, Image: c.Image, State: c.State, Status: c.Status, }) } node := NodeInfo{ ID: "local-01", Hostname: hostname, Role: "standalone", Status: "ready", Availability: "active", IP: "127.0.0.1", DockerVersion: "unknown", Labels: map[string]string{}, UpdatedAt: time.Now().UTC().Format(time.RFC3339), } respond(w, http.StatusOK, map[string]any{ "nodes": []NodeInfo{node}, "count": 1, "swarmActive": false, "containers": containerInfos, "fetchedAt": time.Now().UTC().Format(time.RFC3339), }) } // GET /api/nodes/stats // Returns live container stats (CPU%, RAM) for containers on this host. func (h *Handler) NodeStats(w http.ResponseWriter, r *http.Request) { containers, err := h.docker.ListContainers() if err != nil { respondError(w, http.StatusInternalServerError, "failed to list containers: "+err.Error()) return } type ContainerStat struct { ID string `json:"id"` Name string `json:"name"` CPUPct float64 `json:"cpuPct"` MemUseMB float64 `json:"memUseMB"` MemLimMB float64 `json:"memLimMB"` MemPct float64 `json:"memPct"` } stats := make([]ContainerStat, 0, len(containers)) for _, c := range containers { s, err := h.docker.GetContainerStats(c.ID) if err != nil { continue } name := c.ID[:12] if len(c.Names) > 0 { name = c.Names[0] if len(name) > 0 && name[0] == '/' { name = name[1:] } } cpuPct := dockerclient.CalcCPUPercent(s) memUse := float64(s.MemoryStats.Usage) / (1024 * 1024) memLim := float64(s.MemoryStats.Limit) / (1024 * 1024) memPct := 0.0 if memLim > 0 { memPct = (memUse / memLim) * 100 } stats = append(stats, ContainerStat{ ID: c.ID[:12], Name: name, CPUPct: round2(cpuPct), MemUseMB: round2(memUse), MemLimMB: round2(memLim), MemPct: round2(memPct), }) } respond(w, http.StatusOK, map[string]any{ "stats": stats, "count": len(stats), "fetchedAt": time.Now().UTC().Format(time.RFC3339), }) } // ─── Helpers ────────────────────────────────────────────────────────────────── func respond(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } func respondError(w http.ResponseWriter, status int, msg string) { respond(w, status, map[string]any{"error": msg}) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } func round2(f float64) float64 { return float64(int(f*100)) / 100 } func init() { _ = fmt.Sprintf // suppress unused import }