Files
HeatGuard/internal/cli/web_handlers.go
vikingowl 1c9db02334 feat: add web UI with full CRUD setup page
Add server-side rendered setup UI accessible via `heatwave web`.
The dashboard is now re-rendered per request and includes a nav bar
linking to the new /setup page. Setup provides full CRUD for profiles,
rooms, devices, occupants, AC units (with room assignment), scenario
toggles, and forecast fetching — all via POST/redirect/GET forms.

- Add ShowNav field to DashboardData for conditional nav bar
- Extract fetchForecastForProfile() for reuse by web handler
- Create setup.html.tmpl with Tailwind-styled entity sections
- Create web_handlers.go with 15 route handlers and flash cookies
- Switch web.go from pre-rendered to per-request dashboard rendering
- Graceful dashboard fallback when no forecast data exists
2026-02-09 10:39:00 +01:00

474 lines
14 KiB
Go

package cli
import (
_ "embed"
"fmt"
"html/template"
"net/http"
"strconv"
"github.com/cnachtigall/heatwave-autopilot/internal/report"
"github.com/cnachtigall/heatwave-autopilot/internal/static"
"github.com/cnachtigall/heatwave-autopilot/internal/store"
)
//go:embed templates/setup.html.tmpl
var setupTmplStr string
var setupTmpl *template.Template
func init() {
funcs := template.FuncMap{
"mul": func(a, b float64) float64 { return a * b },
}
setupTmpl = template.Must(template.New("setup").Funcs(funcs).Parse(setupTmplStr))
}
// acRoomAssignment pairs a room ID with its name for display.
type acRoomAssignment struct {
RoomID int64
RoomName string
}
// acUnitView wraps an AC unit with its room assignments for the template.
type acUnitView struct {
store.ACUnit
AssignedRoomIDs []acRoomAssignment
}
// setupData holds all data for the setup page template.
type setupData struct {
CSS template.CSS
Flash string
Profile *store.Profile
Profiles []store.Profile
Rooms []store.Room
Devices []store.Device
Occupants []store.Occupant
ACUnits []acUnitView
Toggles map[string]bool
LastFetch string
}
func loadSetupData(w http.ResponseWriter, r *http.Request) setupData {
sd := setupData{
CSS: template.CSS(static.TailwindCSS),
}
sd.Flash = getFlash(w, r)
sd.Profiles, _ = db.ListProfiles()
p, err := getActiveProfile()
if err != nil || p == nil {
return sd
}
sd.Profile = p
sd.Rooms, _ = db.ListRooms(p.ID)
sd.Devices, _ = db.ListAllDevices(p.ID)
sd.Occupants, _ = db.ListAllOccupants(p.ID)
// Build room name lookup
roomNames := make(map[int64]string)
for _, r := range sd.Rooms {
roomNames[r.ID] = r.Name
}
acUnits, _ := db.ListACUnits(p.ID)
for _, ac := range acUnits {
view := acUnitView{ACUnit: ac}
roomIDs, _ := db.GetACRoomAssignments(ac.ID)
for _, rid := range roomIDs {
view.AssignedRoomIDs = append(view.AssignedRoomIDs, acRoomAssignment{
RoomID: rid,
RoomName: roomNames[rid],
})
}
sd.ACUnits = append(sd.ACUnits, view)
}
toggles, _ := db.GetToggles(p.ID)
sd.Toggles = make(map[string]bool)
for _, t := range toggles {
sd.Toggles[t.Name] = t.Active
}
lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo")
if err == nil {
sd.LastFetch = lastFetch.Format("2006-01-02 15:04")
}
return sd
}
func setupHandler(w http.ResponseWriter, r *http.Request) {
sd := loadSetupData(w, r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := setupTmpl.Execute(w, sd); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func dashboardHandler(dateStr string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := buildReportData(dateStr)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html><html><head><style>%s</style></head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
<span class="font-bold text-lg">Heatwave</span>
<a href="/" class="font-medium text-blue-600 dark:text-blue-400 underline">Dashboard</a>
<a href="/setup" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Setup</a>
</div>
</nav>
<div class="container mx-auto py-4 px-4">
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
<div class="p-4 bg-yellow-50 dark:bg-yellow-950 border-l-4 border-yellow-400 rounded">
<p class="font-medium">Cannot load dashboard</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">%s</p>
<p class="mt-3"><a href="/setup" class="text-blue-600 dark:text-blue-400 underline">Go to Setup</a> to configure your profile and fetch forecast data.</p>
</div>
</div></body></html>`, static.TailwindCSS, err.Error())
return
}
data.ShowNav = true
html, err := report.GenerateString(data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
}
// --- CRUD Handlers ---
func profileAddHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
name := r.FormValue("name")
lat := parseFloatOr(r.FormValue("latitude"), 0)
lon := parseFloatOr(r.FormValue("longitude"), 0)
tz := r.FormValue("timezone")
if tz == "" {
tz = "Europe/Berlin"
}
_, err := db.CreateProfile(name, lat, lon, tz)
if err != nil {
setFlash(w, "Error creating profile: "+err.Error())
} else {
setFlash(w, "Profile "+name+" created.")
}
http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther)
}
func profileDeleteHandler(w http.ResponseWriter, r *http.Request) {
id := parseIntOr(r.PathValue("id"), 0)
if id == 0 {
setFlash(w, "Invalid profile ID.")
http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther)
return
}
if err := db.DeleteProfile(id); err != nil {
setFlash(w, "Error deleting profile: "+err.Error())
} else {
setFlash(w, "Profile deleted.")
}
http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther)
}
func roomAddHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
p, err := getActiveProfile()
if err != nil {
setFlash(w, "No active profile.")
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
return
}
name := r.FormValue("name")
areaSqm := parseFloatOr(r.FormValue("area_sqm"), 20)
floor := int(parseIntOr(r.FormValue("floor"), 0))
orientation := r.FormValue("orientation")
shadingType := r.FormValue("shading_type")
shadingFactor := parseFloatOr(r.FormValue("shading_factor"), 1.0)
ventilation := r.FormValue("ventilation")
insulation := r.FormValue("insulation")
params := store.RoomParams{
CeilingHeightM: parseFloatOr(r.FormValue("ceiling_height_m"), 2.50),
VentilationACH: parseFloatOr(r.FormValue("ventilation_ach"), 1.5),
WindowFraction: parseFloatOr(r.FormValue("window_fraction"), 0.30),
SHGC: parseFloatOr(r.FormValue("shgc"), 0.60),
}
_, err = db.CreateRoom(p.ID, name, areaSqm, floor, orientation, shadingType, shadingFactor, ventilation, insulation, params)
if err != nil {
setFlash(w, "Error creating room: "+err.Error())
} else {
setFlash(w, "Room "+name+" created.")
}
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
}
func roomDeleteHandler(w http.ResponseWriter, r *http.Request) {
id := parseIntOr(r.PathValue("id"), 0)
if id == 0 {
setFlash(w, "Invalid room ID.")
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
return
}
if err := db.DeleteRoom(id); err != nil {
setFlash(w, "Error deleting room: "+err.Error())
} else {
setFlash(w, "Room deleted.")
}
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
}
func deviceAddHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
roomID := parseIntOr(r.FormValue("room_id"), 0)
name := r.FormValue("name")
deviceType := r.FormValue("device_type")
wattsIdle := parseFloatOr(r.FormValue("watts_idle"), 10)
wattsTypical := parseFloatOr(r.FormValue("watts_typical"), 80)
wattsPeak := parseFloatOr(r.FormValue("watts_peak"), 200)
dutyCycle := parseFloatOr(r.FormValue("duty_cycle"), 0.5)
_, err := db.CreateDevice(roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle)
if err != nil {
setFlash(w, "Error creating device: "+err.Error())
} else {
setFlash(w, "Device "+name+" created.")
}
http.Redirect(w, r, "/setup#devices", http.StatusSeeOther)
}
func deviceDeleteHandler(w http.ResponseWriter, r *http.Request) {
id := parseIntOr(r.PathValue("id"), 0)
if id == 0 {
setFlash(w, "Invalid device ID.")
http.Redirect(w, r, "/setup#devices", http.StatusSeeOther)
return
}
if err := db.DeleteDevice(id); err != nil {
setFlash(w, "Error deleting device: "+err.Error())
} else {
setFlash(w, "Device deleted.")
}
http.Redirect(w, r, "/setup#devices", http.StatusSeeOther)
}
func occupantAddHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
roomID := parseIntOr(r.FormValue("room_id"), 0)
count := int(parseIntOr(r.FormValue("count"), 1))
activityLevel := r.FormValue("activity_level")
vulnerable := r.FormValue("vulnerable") == "true"
_, err := db.CreateOccupant(roomID, count, activityLevel, vulnerable)
if err != nil {
setFlash(w, "Error creating occupant: "+err.Error())
} else {
setFlash(w, "Occupant added.")
}
http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther)
}
func occupantDeleteHandler(w http.ResponseWriter, r *http.Request) {
id := parseIntOr(r.PathValue("id"), 0)
if id == 0 {
setFlash(w, "Invalid occupant ID.")
http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther)
return
}
if err := db.DeleteOccupant(id); err != nil {
setFlash(w, "Error deleting occupant: "+err.Error())
} else {
setFlash(w, "Occupant deleted.")
}
http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther)
}
func acAddHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
p, err := getActiveProfile()
if err != nil {
setFlash(w, "No active profile.")
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
return
}
name := r.FormValue("name")
acType := r.FormValue("ac_type")
capacityBTU := parseFloatOr(r.FormValue("capacity_btu"), 12000)
efficiencyEER := parseFloatOr(r.FormValue("efficiency_eer"), 10.0)
hasDehumidify := r.FormValue("has_dehumidify") == "true"
_, err = db.CreateACUnit(p.ID, name, acType, capacityBTU, hasDehumidify, efficiencyEER)
if err != nil {
setFlash(w, "Error creating AC unit: "+err.Error())
} else {
setFlash(w, "AC unit "+name+" created.")
}
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
}
func acDeleteHandler(w http.ResponseWriter, r *http.Request) {
id := parseIntOr(r.PathValue("id"), 0)
if id == 0 {
setFlash(w, "Invalid AC unit ID.")
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
return
}
if err := db.DeleteACUnit(id); err != nil {
setFlash(w, "Error deleting AC unit: "+err.Error())
} else {
setFlash(w, "AC unit deleted.")
}
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
}
func acAssignHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
acID := parseIntOr(r.PathValue("id"), 0)
roomID := parseIntOr(r.FormValue("room_id"), 0)
if acID == 0 || roomID == 0 {
setFlash(w, "Invalid AC or room ID.")
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
return
}
if err := db.AssignACToRoom(acID, roomID); err != nil {
setFlash(w, "Error assigning AC: "+err.Error())
} else {
setFlash(w, "AC unit assigned to room.")
}
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
}
func acUnassignHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
acID := parseIntOr(r.PathValue("id"), 0)
roomID := parseIntOr(r.FormValue("room_id"), 0)
if acID == 0 || roomID == 0 {
setFlash(w, "Invalid AC or room ID.")
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
return
}
if err := db.UnassignACFromRoom(acID, roomID); err != nil {
setFlash(w, "Error unassigning AC: "+err.Error())
} else {
setFlash(w, "AC unit unassigned from room.")
}
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
}
func toggleSetHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
p, err := getActiveProfile()
if err != nil {
setFlash(w, "No active profile.")
http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther)
return
}
name := r.FormValue("name")
active := r.FormValue("active") == "true"
if name == "" {
setFlash(w, "Toggle name is required.")
http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther)
return
}
if err := db.SetToggle(p.ID, name, active); err != nil {
setFlash(w, "Error setting toggle: "+err.Error())
} else {
state := "OFF"
if active {
state = "ON"
}
setFlash(w, fmt.Sprintf("Toggle %q set to %s.", name, state))
}
http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther)
}
func forecastFetchHandler(w http.ResponseWriter, r *http.Request) {
p, err := getActiveProfile()
if err != nil {
setFlash(w, "No active profile.")
http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther)
return
}
if err := fetchForecastForProfile(p); err != nil {
setFlash(w, "Forecast fetch failed: "+err.Error())
} else {
setFlash(w, "Forecast fetched successfully.")
}
http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther)
}
// --- Flash helpers ---
const flashCookieName = "heatwave_flash"
func setFlash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: msg,
Path: "/",
MaxAge: 10,
SameSite: http.SameSiteStrictMode,
})
}
func getFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(flashCookieName)
if err != nil {
return ""
}
// Clear the flash cookie immediately
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: "",
Path: "/",
MaxAge: -1,
})
return c.Value
}
// --- Parse helpers ---
func parseFloatOr(s string, def float64) float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return def
}
return v
}
func parseIntOr(s string, def int64) int64 {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return def
}
return v
}
// registerSetupRoutes registers all setup-related routes on the mux.
func registerSetupRoutes(mux *http.ServeMux, dateStr string) {
mux.HandleFunc("GET /{$}", dashboardHandler(dateStr))
mux.HandleFunc("GET /setup", setupHandler)
mux.HandleFunc("POST /setup/profiles/add", profileAddHandler)
mux.HandleFunc("POST /setup/profiles/{id}/delete", profileDeleteHandler)
mux.HandleFunc("POST /setup/rooms/add", roomAddHandler)
mux.HandleFunc("POST /setup/rooms/{id}/delete", roomDeleteHandler)
mux.HandleFunc("POST /setup/devices/add", deviceAddHandler)
mux.HandleFunc("POST /setup/devices/{id}/delete", deviceDeleteHandler)
mux.HandleFunc("POST /setup/occupants/add", occupantAddHandler)
mux.HandleFunc("POST /setup/occupants/{id}/delete", occupantDeleteHandler)
mux.HandleFunc("POST /setup/ac/add", acAddHandler)
mux.HandleFunc("POST /setup/ac/{id}/delete", acDeleteHandler)
mux.HandleFunc("POST /setup/ac/{id}/assign", acAssignHandler)
mux.HandleFunc("POST /setup/ac/{id}/unassign", acUnassignHandler)
mux.HandleFunc("POST /setup/toggles/set", toggleSetHandler)
mux.HandleFunc("POST /setup/forecast/fetch", forecastFetchHandler)
}