package main import ( "context" "html/template" "log/slog" "net/http" "os" "sync" "time" "github.com/blakeridgway/heloha/internal/radar" "github.com/blakeridgway/heloha/internal/server" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) const ingestEvery = 2 * time.Minute var radarSites = []string{ "KTLX", // Oklahoma City, OK "KINX", // Tulsa, OK "KVNX", // Vance AFB, OK "KFDR", // Frederick, OK "KSRX", // Fort Smith, AR "KLZK", // Little Rock, AR "KSHV", // Shreveport, LA "KFWS", // Fort Worth, TX "KDYX", // Dyess AFB, TX "KAMA", // Amarillo, TX "KLBB", // Lubbock, TX "KICT", // Wichita, KS "KDDC", // Dodge City, KS "KSGF", // Springfield, MO "KEAX", // Kansas City, MO } func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) ctx := context.Background() fetcher, err := radar.NewFetcher(ctx) if err != nil { logger.Error("init fetcher", "err", err) os.Exit(1) } store := server.NewRadarStore() cache := server.NewTileCache() ingestAll(ctx, logger, fetcher, store, cache) go func() { tick := time.NewTicker(ingestEvery) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: ingestAll(ctx, logger, fetcher, store, cache) } } }() indexTmpl := template.Must(template.ParseFiles("web/templates/index.html")) r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") indexTmpl.Execute(w, nil) }) // Composite tile: product + frame in path (new) r.Get("/api/v1/tile/composite/{product}/{frame}/{z}/{x}/{y}.png", server.CompositeTileHandler(store, cache)) // Legacy composite route (product=reflectivity, frame=latest) r.Get("/api/v1/tile/composite/{z}/{x}/{y}.png", server.CompositeTileHandler(store, cache)) // Per-site tile r.Get("/api/v1/tile/{site}/{z}/{x}/{y}.png", server.TileHandler(store, cache)) // Frame metadata for the loop player r.Get("/api/v1/frames", server.FramesHandler(store)) // Scan time (KTLX reference) r.Get("/api/v1/scan-time", func(w http.ResponseWriter, r *http.Request) { p := store.GetLatest("ktlx", "reflectivity") if p == nil { w.Write([]byte("—")) return } w.Write([]byte(p.Time.Format("15:04 UTC"))) }) srv := &http.Server{ Addr: ":8080", Handler: r, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } logger.Info("starting server", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil { logger.Error("server exited", "err", err) os.Exit(1) } } // ingestAll fetches reflectivity + velocity for every site concurrently. func ingestAll(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, cache *server.TileCache) { var wg sync.WaitGroup for _, site := range radarSites { wg.Add(2) go func(s string) { defer wg.Done() if err := ingestRefl(ctx, logger, f, store, s); err != nil { logger.Warn("refl ingest failed", "site", s, "err", err) } }(site) go func(s string) { defer wg.Done() if err := ingestVel(ctx, logger, f, store, s); err != nil { logger.Warn("vel ingest failed", "site", s, "err", err) } }(site) } wg.Wait() cache.Invalidate() logger.Info("ingest cycle complete") } func ingestRefl(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, site string) error { data, err := f.FetchLatest(ctx, site) if err != nil { return err } p, err := radar.Parse(site, data) if err != nil { return err } store.Set(site, "reflectivity", p) logger.Info("refl ok", "site", site, "time", p.Time, "radials", len(p.Radials)) return nil } func ingestVel(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, site string) error { data, err := f.FetchVelocity(ctx, site) if err != nil { return err } p, err := radar.ParseVelocity(site, data) if err != nil { return err } store.Set(site, "velocity", p) logger.Info("vel ok", "site", site, "time", p.Time) return nil }