Files
kyle 4bc5115f24 Add the admin endpoints, the crossbar binary and the fake upstream
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-25 02:32:50 -07:00

103 lines
2.8 KiB
Go

// Package admin serves the operator's view of crossbar: the health table and the routes as JSON,
// mounted at /_crossbar/ on the same listener as the proxy. The shape of /_crossbar/hosts is
// fixed so operators can read why a request went where it went.
package admin
import (
"encoding/json"
"net/http"
"time"
"git.wntrmute.dev/kyle/crossbar/internal/config"
"git.wntrmute.dev/kyle/crossbar/internal/health"
)
// Hosts is what the admin handler needs from the health table.
type Hosts interface {
All() map[string]health.Status
}
type HostView struct {
Healthy bool `json:"healthy"`
Loaded []string `json:"loaded"` // never null: an empty slice when nothing is loaded
LastOK string `json:"last_ok"` // time.RFC3339 in UTC, or "" if never
LastErr string `json:"last_err"`
}
type RouteView struct {
Hosts []string `json:"hosts"`
DefaultModel string `json:"default_model"`
}
// Handler serves GET /_crossbar/hosts and GET /_crossbar/routes. Any other method on those paths is
// a 405 with an Allow: GET header; anything else under the handler is a 404.
func Handler(cfg *config.Config, h Hosts) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/_crossbar/hosts", hostsHandler(h))
mux.HandleFunc("/_crossbar/routes", routesHandler(cfg))
mux.HandleFunc("/", notFound)
return mux
}
func hostsHandler(h Hosts) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
wrongMethod(w)
return
}
views := make(map[string]HostView, len(h.All()))
for name, s := range h.All() {
views[name] = hostView(s)
}
writeJSON(w, http.StatusOK, views)
}
}
func routesHandler(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
wrongMethod(w)
return
}
views := make(map[string]RouteView, len(cfg.Routes))
for name, route := range cfg.Routes {
hosts := make([]string, len(route.Hosts))
copy(hosts, route.Hosts)
views[name] = RouteView{Hosts: hosts, DefaultModel: route.DefaultModel}
}
writeJSON(w, http.StatusOK, views)
}
}
func hostView(s health.Status) HostView {
loaded := s.Loaded
if loaded == nil {
loaded = []string{}
}
lastOK := ""
if !s.LastOK.IsZero() {
lastOK = s.LastOK.UTC().Format(time.RFC3339)
}
return HostView{
Healthy: s.Healthy,
Loaded: loaded,
LastOK: lastOK,
LastErr: s.LastErr,
}
}
func wrongMethod(w http.ResponseWriter) {
w.Header().Set("Allow", "GET")
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
func notFound(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}