generated from rmcguire/go-server-with-otel
118 lines
3.2 KiB
Go
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))
|
|
}
|