/* Package econetui serves a small admin UI at / for viewing EcoNet devices and changing their operating mode. It renders server-side HTML (html/template with embedded assets) and uses HTMX for interactivity; device reads and mode writes go through the in-process gRPC server, exactly like the MCP packages. Auth is left to the edge (oauth2-proxy) — this package serves the human surface at / and the mode-write at /ui/, leaving /health, /metrics, and /api untouched. */ package econetui import ( "context" "embed" "fmt" "html/template" "io/fs" "net/http" "strings" "time" "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts" "github.com/rs/zerolog" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc" ) //go:embed templates/*.html var templatesFS embed.FS //go:embed static var staticFS embed.FS // pendingTTL bounds how long an optimistic mode change is shown before the UI // falls back to the reported state. const pendingTTL = 90 * time.Second type EconetUIServer struct { ctx context.Context cfg *config.ServiceConfig log *zerolog.Logger econetGRPC *econetgrpc.EconetGRPCServer tmpl *template.Template pending *pendingStore } func NewEconetUIServer(ctx context.Context, cfg *config.ServiceConfig, grpc *econetgrpc.EconetGRPCServer, ) *EconetUIServer { tmpl := template.Must(template.New("").Funcs(template.FuncMap{ "prettyMode": prettyMode, "fillClass": fillClass, "fillWidth": fillWidth, }).ParseFS(templatesFS, "templates/*.html")) return &EconetUIServer{ ctx: ctx, cfg: cfg, log: zerolog.Ctx(ctx), econetGRPC: grpc, tmpl: tmpl, pending: newPendingStore(pendingTTL), } } func (s *EconetUIServer) GetHandlers() []opts.HTTPHandler { staticSub, _ := fs.Sub(staticFS, "static") mux := http.NewServeMux() mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /ui/devices", s.handleDevices) mux.HandleFunc("GET /ui/devices/{serial}/debug", s.handleDebug) mux.HandleFunc("POST /ui/devices/{serial}/mode", s.handleSetMode) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) s.log.Debug().Msg("Econet admin UI ready at /") return []opts.HTTPHandler{{Prefix: "/", Handler: mux}} } // prettyMode turns a mode slug ("heat-pump") into a display label ("Heat Pump"). func prettyMode(slug string) string { switch slug { case "": return "Unknown" case "electric-gas": return "Electric/Gas" } words := strings.Split(slug, "-") for i, w := range words { if w != "" { words[i] = strings.ToUpper(w[:1]) + w[1:] } } return strings.Join(words, " ") } // fillClass buckets a hot-water fill percentage into a color class. It takes // int32 to match the proto Device field (html/template is strict about types). func fillClass(pct int32) string { switch { case pct >= 66: return "fill-high" case pct >= 33: return "fill-mid" default: return "fill-low" } } // fillWidth returns a safe CSS width for the fill bar. func fillWidth(pct int32) template.CSS { if pct < 0 { pct = 0 } return template.CSS(fmt.Sprintf("width:%d%%", pct)) }