Files
2026-07-16 16:32:44 -04:00

118 lines
3.2 KiB
Go

/*
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))
}