package main import ( "context" "encoding/json" "fmt" "log" "net/http" "sync" "time" "github.com/kami/maven/internal/ipc" ) // The three sibling services Maven coordinates are headless JSON APIs (no web UI // of their own — arch §16: mavweb IS their human surface). This page is that // surface: a read-only observability panel that GETs each sibling's list // endpoints and renders them. Never mutates — enabling/executing stays on the // authed /tools + voice paths, never here. // ecoURLs holds the sibling base URLs; empty ⇒ that panel shows "not configured". type ecoURLs struct { nexus, praxis, hexis string } var ecoClient = &http.Client{Timeout: 6 * time.Second} // getEco GETs path off base and decodes the JSON array into out. Returns a // human error string (empty on success) — honest per-panel state, no spinner. func getEco(ctx context.Context, base, path string, out any) string { if base == "" { return "not configured" } req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil) if err != nil { return err.Error() } resp, err := ecoClient.Do(req) if err != nil { return "unreachable" } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Sprintf("http %d", resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return "bad json" } return "" } // Minimal projections of each sibling's list payload — only the columns the // panel shows. Extra JSON fields are ignored. type ecoEntity struct { ID string `json:"id"` Type string `json:"type"` DisplayName string `json:"display_name"` State string `json:"state"` UpdatedAt time.Time `json:"updated_at"` } type ecoItem struct { ID string `json:"id"` Kind string `json:"kind"` Title string `json:"title"` State string `json:"state"` Importance int `json:"importance"` LastSeenAt time.Time `json:"last_seen_at"` } type ecoCap struct { ID string `json:"id"` Name string `json:"name"` Provider string `json:"provider"` Operation string `json:"operation"` Risk string `json:"risk"` ReadOnly bool `json:"read_only"` } type ecoPanel[T any] struct { Rows []T Err string } type ecoData struct { Nexus ecoPanel[ecoEntity] Praxis ecoPanel[ecoItem] Hexis ecoPanel[ecoCap] Calls ecoPanel[ipc.EcosystemTrace] } // handleEcosystem renders the three sibling panels plus Maven's own log of the // calls she made to them. The call log comes from core, not from the siblings: // it is what Maven saw, including the hops that never got an answer. func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core ipc.CoreAPI) { ctx := r.Context() var d ecoData var wg sync.WaitGroup wg.Add(3) go func() { defer wg.Done() d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows) }() go func() { defer wg.Done() d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows) }() go func() { defer wg.Done(); d.Hexis.Err = getEco(ctx, urls.hexis, "/api/v1/capabilities", &d.Hexis.Rows) }() wg.Wait() if core == nil { d.Calls.Err = "not configured" } else if rows, err := core.RecentEcosystemTraces(ctx, 50); err != nil { log.Printf("ecosystem traces: %v", err) d.Calls.Err = "core read failed" } else { d.Calls.Rows = rows } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := ecosystemTmpl.Execute(w, d); err != nil { log.Printf("ecosystem render: %v", err) } }