generated from rmcguire/go-server-with-otel
add ui, new config opts
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package econetui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
|
||||
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc"
|
||||
)
|
||||
|
||||
// cardView is the template model for one device card.
|
||||
type cardView struct {
|
||||
Device *pb.Device
|
||||
Icon string // static asset path for the device type
|
||||
Modes []modeOption // settable modes for the dropdown
|
||||
Pending string // pretty label of an in-flight mode change, if any
|
||||
Error string // last mode-change error, if any
|
||||
}
|
||||
|
||||
type modeOption struct {
|
||||
Value string // proto enum name, e.g. MODE_HEAT_PUMP
|
||||
Label string // display label, e.g. Heat Pump
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type pageView struct {
|
||||
Devices []cardView
|
||||
}
|
||||
|
||||
func (s *EconetUIServer) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// A full page load is a "fresh get": show reported state, ignore pending.
|
||||
s.render(w, "layout", pageView{Devices: s.deviceViews(r.Context(), false)})
|
||||
}
|
||||
|
||||
func (s *EconetUIServer) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
// The polling refresh honors pending so an in-flight change stays visible
|
||||
// until it lands or its TTL lapses.
|
||||
s.render(w, "devices", pageView{Devices: s.deviceViews(r.Context(), true)})
|
||||
}
|
||||
|
||||
func (s *EconetUIServer) handleSetMode(w http.ResponseWriter, r *http.Request) {
|
||||
serial := r.PathValue("serial")
|
||||
modeName := r.FormValue("mode")
|
||||
|
||||
modeVal, ok := pb.Mode_value[modeName]
|
||||
if !ok || pb.Mode(modeVal) == pb.Mode_MODE_UNSPECIFIED {
|
||||
s.renderCard(w, r.Context(), serial, "invalid mode "+modeName)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.econetGRPC.SetMode(r.Context(), &pb.SetModeRequest{
|
||||
SerialNumber: serial,
|
||||
Mode: pb.Mode(modeVal),
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Warn().Err(err).Str("serial", serial).Msg("ui: set mode failed")
|
||||
s.renderCard(w, r.Context(), serial, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.pending.set(serial, econetgrpc.ModeSlug(pb.Mode(modeVal)))
|
||||
s.renderCard(w, r.Context(), serial, "")
|
||||
}
|
||||
|
||||
// handleDebug returns the raw EcoNet payload for a device, pretty-printed, for
|
||||
// the bug-icon modal.
|
||||
func (s *EconetUIServer) handleDebug(w http.ResponseWriter, r *http.Request) {
|
||||
serial := r.PathValue("serial")
|
||||
raw, ok := s.econetGRPC.DeviceRawJSON(serial)
|
||||
if !ok {
|
||||
s.render(w, "debug", "No raw payload captured for "+serial+" yet.")
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
||||
buf.Write(raw)
|
||||
}
|
||||
s.render(w, "debug", buf.String())
|
||||
}
|
||||
|
||||
// deviceViews builds card views for all devices. When usePending is true, an
|
||||
// in-flight mode change is shown as a pending badge (and cleared once the
|
||||
// reported mode catches up).
|
||||
func (s *EconetUIServer) deviceViews(ctx context.Context, usePending bool) []cardView {
|
||||
resp, err := s.econetGRPC.ListDevices(ctx, &pb.ListDevicesRequest{})
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("ui: list devices failed")
|
||||
return nil
|
||||
}
|
||||
views := make([]cardView, 0, len(resp.GetDevices()))
|
||||
for _, d := range resp.GetDevices() {
|
||||
views = append(views, s.toCardView(d, usePending))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// renderCard re-renders a single device's card, used for the HTMX swap after a
|
||||
// mode change. A successful change is optimistic (pending badge shown).
|
||||
func (s *EconetUIServer) renderCard(w http.ResponseWriter, ctx context.Context, serial, errMsg string) {
|
||||
resp, err := s.econetGRPC.GetDevice(ctx, &pb.GetDeviceRequest{SerialNumber: serial})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
cv := s.toCardView(resp.GetDevice(), true)
|
||||
cv.Error = errMsg
|
||||
s.render(w, "card", cv)
|
||||
}
|
||||
|
||||
func (s *EconetUIServer) toCardView(d *pb.Device, usePending bool) cardView {
|
||||
cv := cardView{Device: d, Icon: iconFor(d.GetGenericType())}
|
||||
|
||||
for _, m := range d.GetSupportedModes() {
|
||||
slug := econetgrpc.ModeSlug(m)
|
||||
cv.Modes = append(cv.Modes, modeOption{
|
||||
Value: m.String(),
|
||||
Label: prettyMode(slug),
|
||||
Selected: slug == d.GetMode(),
|
||||
})
|
||||
}
|
||||
|
||||
if usePending {
|
||||
if desired, ok := s.pending.get(d.GetSerialNumber()); ok {
|
||||
if desired == d.GetMode() {
|
||||
s.pending.clear(d.GetSerialNumber()) // reported caught up
|
||||
} else {
|
||||
cv.Pending = prettyMode(desired)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cv
|
||||
}
|
||||
|
||||
func (s *EconetUIServer) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.log.Error().Err(err).Str("template", name).Msg("ui: render failed")
|
||||
}
|
||||
}
|
||||
|
||||
// iconFor maps a device's generic type to an embedded SVG asset. It matches
|
||||
// loosely (case/spacing/underscore-insensitive substrings) because the reported
|
||||
// @TYPE varies in form across firmware (e.g. "heatpumpWaterHeater", "HeatPump",
|
||||
// "Hybrid Electric"); unrecognized types fall back to a generic icon.
|
||||
func iconFor(genericType string) string {
|
||||
t := strings.NewReplacer(" ", "", "_", "", "-", "").Replace(strings.ToLower(genericType))
|
||||
switch {
|
||||
case strings.Contains(t, "heatpump"), strings.Contains(t, "hybrid"):
|
||||
return "/static/icons/heatpump.svg"
|
||||
case strings.Contains(t, "tankless"):
|
||||
return "/static/icons/tankless.svg"
|
||||
case strings.Contains(t, "gas"):
|
||||
return "/static/icons/gas.svg"
|
||||
case strings.Contains(t, "electric"):
|
||||
return "/static/icons/electric.svg"
|
||||
default:
|
||||
return "/static/icons/unknown.svg"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user