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"` OpenMeteoURL string `json:"open_meteo_url,omitempty"` 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.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 (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.ForecastSeriesLatest(r.Context(), s.site.Name, s.model) if err != nil { http.Error(w, "failed to query forecast", http.StatusInternalServerError) log.Printf("web dashboard forecast error: %v", err) return } openMeteoURL, err := providers.OpenMeteoRequestURL(s.site, s.model) if err != nil { log.Printf("web dashboard open-meteo url error: %v", err) } resp := dashboardResponse{ GeneratedAt: time.Now().UTC(), Site: s.site.Name, Model: s.model, OpenMeteoURL: openMeteoURL, 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") }