Files
mc-router/internal/automc/uipage.go
T
claude-timemachine e2ce0453fa
CI / validate (push) Successful in 12s
CI / docker (push) Successful in 15s
automc: operator UI on AUTOMC_UI_BINDING
Adds a separate HTTP server (not the upstream API on :25590) for the
operator dashboard. Single-page UI with two panes:
  * routes table — current pg-synced mappings, polled every 2s
  * logs — SSE stream backed by a logrus hook + 500-entry ring buffer

Opt-in via AUTOMC_UI_BINDING (e.g. ":8082"); unset = no-op, behaves
exactly like upstream. Designed to live behind server-manager's
/infra/mc-router/* reverse-proxy.

Patch is internal/automc-only, same fork philosophy as the rest —
upstream files stay verbatim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:03:48 +02:00

120 lines
3.2 KiB
Go

package automc
import (
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"sort"
"time"
"github.com/itzg/mc-router/server"
"github.com/sirupsen/logrus"
)
//go:embed static/*
var staticFS embed.FS
// startUI starts a separate HTTP server on uiBinding serving the operator
// dashboard (embedded index.html), an SSE log feed, and a JSON snapshot of
// the current route table. The upstream JSON API on API_BINDING is left
// untouched so existing tooling keeps working.
func startUI(ctx context.Context, uiBinding string, bus *LogBus) {
mux := http.NewServeMux()
sub, err := fs.Sub(staticFS, "static")
if err != nil {
logrus.WithError(err).Error("automc ui: embed misconfigured")
return
}
mux.Handle("GET /", http.FileServer(http.FS(sub)))
mux.HandleFunc("GET /api/routes", routesSnapshotHandler)
mux.HandleFunc("GET /api/logs", sseLogsHandler(bus))
srv := &http.Server{
Addr: uiBinding,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
go func() {
logrus.WithField("binding", uiBinding).Info("automc: ui server listening")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logrus.WithError(err).Error("automc ui server failed")
}
}()
}
// RouteSnapshot is one row of the routes table the UI renders. Same shape as
// the upstream /routes JSON but flatter — the UI doesn't need both backend
// and scalingTarget shown separately.
type RouteSnapshot struct {
ServerAddress string `json:"server_address"`
Backend string `json:"backend"`
}
func routesSnapshotHandler(w http.ResponseWriter, _ *http.Request) {
mappings := server.Routes.GetMappings()
out := make([]RouteSnapshot, 0, len(mappings))
for addr, backend := range mappings {
out = append(out, RouteSnapshot{ServerAddress: addr, Backend: backend})
}
sort.Slice(out, func(i, j int) bool { return out[i].ServerAddress < out[j].ServerAddress })
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"routes": out,
"at": time.Now(),
})
}
func sseLogsHandler(bus *LogBus) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
for _, e := range bus.Backlog() {
writeSSEEvent(w, e)
}
flusher.Flush()
ch := bus.Subscribe()
defer bus.Unsubscribe(ch)
hb := time.NewTicker(30 * time.Second)
defer hb.Stop()
for {
select {
case <-r.Context().Done():
return
case e := <-ch:
writeSSEEvent(w, e)
flusher.Flush()
case <-hb.C:
_, _ = io.WriteString(w, ":hb\n\n")
flusher.Flush()
}
}
}
}
func writeSSEEvent(w io.Writer, e LogEntry) {
fmt.Fprintf(w, "data: {\"time\":%q,\"level\":%q,\"msg\":%q,\"attrs\":%q}\n\n",
e.Time.Format(time.RFC3339Nano), e.Level, e.Msg, e.Attrs)
}