41a7e39754
API shape:
GET /api/connections → GET /api/tunnels
body: {"connections": […]} → {"tunnels": […]}
Type rename (package stays "bridge" — internal):
Valve → Listener
clientBridge → tunnel
ConnSnapshot → TunnelSnapshot
Log messages mirror the new vocab ("listener open/close", "tunnel
open/idle evict/forward failed"). UI header is now "Active tunnels"
and the empty state reads "no active tunnels".
server-manager's dashboard polls /infra/svc-proxy/api/tunnels and
shows "N tunnels" on the svc-proxy infra card.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
79 lines
1.9 KiB
Go
79 lines
1.9 KiB
Go
// Package httpsrv exposes the svc-proxy operator UI + JSON API. Designed to
|
|
// be reverse-proxied behind server-manager (no auth/TLS at this layer; the
|
|
// listener should bind to the container network only).
|
|
package httpsrv
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"git.timemachine.center/timemachine/svc-proxy/internal/bridge"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
type Server struct {
|
|
addr string
|
|
mgr *bridge.Manager
|
|
bus *LogBus
|
|
srv *http.Server
|
|
}
|
|
|
|
func New(addr string, mgr *bridge.Manager, bus *LogBus) *Server {
|
|
mux := http.NewServeMux()
|
|
s := &Server{
|
|
addr: addr,
|
|
mgr: mgr,
|
|
bus: bus,
|
|
}
|
|
|
|
sub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
panic(err) // embed.FS misconfigured at build time
|
|
}
|
|
mux.Handle("GET /", http.FileServer(http.FS(sub)))
|
|
mux.HandleFunc("GET /api/tunnels", s.handleTunnels)
|
|
mux.HandleFunc("GET /api/logs", sseLogs(bus))
|
|
|
|
s.srv = &http.Server{
|
|
Addr: addr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Run blocks until ctx is cancelled or the server errors.
|
|
func (s *Server) Run(ctx context.Context) error {
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = s.srv.Shutdown(shutCtx)
|
|
}()
|
|
slog.Info("http server listening", "addr", s.addr)
|
|
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleTunnels(w http.ResponseWriter, _ *http.Request) {
|
|
snap := s.mgr.Snapshot()
|
|
// Sort by most-recently-active first so the UI can render top-down.
|
|
sort.Slice(snap, func(i, j int) bool { return snap[i].LastSeen.After(snap[j].LastSeen) })
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"tunnels": snap,
|
|
"at": time.Now(),
|
|
})
|
|
}
|