Files
2026-01-28 08:51:41 +11:00

199 lines
4.8 KiB
Go

package main
import (
"context"
"embed"
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
"time"
"go-weatherstation/internal/db"
"go-weatherstation/internal/providers"
)
//go:embed web/*
var webFS embed.FS
type webServer struct {
db *db.DB
site providers.Site
model string
}
type dashboardResponse struct {
GeneratedAt time.Time `json:"generated_at"`
Site string `json:"site"`
Model string `json:"model"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
Observations []db.ObservationPoint `json:"observations"`
Forecast db.ForecastSeries `json:"forecast"`
Latest *db.ObservationPoint `json:"latest"`
}
func runWebServer(ctx context.Context, d *db.DB, site providers.Site, model, addr string) error {
sub, err := fs.Sub(webFS, "web")
if err != nil {
return err
}
ws := &webServer{
db: d,
site: site,
model: model,
}
mux := http.NewServeMux()
mux.HandleFunc("/api/dashboard", ws.handleDashboard)
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
})
mux.HandleFunc("/chart", func(w http.ResponseWriter, r *http.Request) {
serveIndex(w, sub)
})
mux.HandleFunc("/chart/", func(w http.ResponseWriter, r *http.Request) {
serveIndex(w, sub)
})
mux.Handle("/", http.FileServer(http.FS(sub)))
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- srv.ListenAndServe()
}()
log.Printf("web ui listening addr=%s", addr)
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
return nil
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func serveIndex(w http.ResponseWriter, sub fs.FS) {
b, err := fs.ReadFile(sub, "index.html")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
}
func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
rangeStr := r.URL.Query().Get("range")
if rangeStr == "" {
rangeStr = "24h"
}
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
bucket := r.URL.Query().Get("bucket")
if bucket == "" {
bucket = "5m"
}
if bucket != "1m" && bucket != "5m" {
http.Error(w, "invalid bucket", http.StatusBadRequest)
return
}
var (
start time.Time
end time.Time
err error
)
if startStr != "" || endStr != "" {
if startStr == "" || endStr == "" {
http.Error(w, "start and end required", http.StatusBadRequest)
return
}
start, err = parseTimeParam(startStr)
if err != nil {
http.Error(w, "invalid start", http.StatusBadRequest)
return
}
end, err = parseTimeParam(endStr)
if err != nil {
http.Error(w, "invalid end", http.StatusBadRequest)
return
}
} else {
rangeDur, err := time.ParseDuration(rangeStr)
if err != nil || rangeDur <= 0 {
http.Error(w, "invalid range", http.StatusBadRequest)
return
}
end = time.Now().UTC()
start = end.Add(-rangeDur)
}
observations, err := s.db.ObservationSeries(r.Context(), s.site.Name, bucket, start, end)
if err != nil {
http.Error(w, "failed to query observations", http.StatusInternalServerError)
log.Printf("web dashboard observations error: %v", err)
return
}
latest, err := s.db.LatestObservation(r.Context(), s.site.Name)
if err != nil {
http.Error(w, "failed to query latest observation", http.StatusInternalServerError)
log.Printf("web dashboard latest error: %v", err)
return
}
forecast, err := s.db.ForecastSeriesRange(r.Context(), s.site.Name, s.model, start, end)
if err != nil {
http.Error(w, "failed to query forecast", http.StatusInternalServerError)
log.Printf("web dashboard forecast error: %v", err)
return
}
resp := dashboardResponse{
GeneratedAt: time.Now().UTC(),
Site: s.site.Name,
Model: s.model,
RangeStart: start,
RangeEnd: end,
Observations: observations,
Forecast: forecast,
Latest: latest,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("web dashboard encode error: %v", err)
}
}
func parseTimeParam(v string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, nil
}
if t, err := time.Parse("2006-01-02", v); err == nil {
return t, nil
}
return time.Time{}, errors.New("unsupported time format")
}