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
474 lines
14 KiB
Go
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)
|
|
}
|