feat: add OpenWeatherMap provider, retry transport, and forecast config UI
Add OpenWeatherMap One Call API 3.0 as alternative weather provider with configurable selection in the Forecast tab. Includes server-side retry transport with exponential backoff for all weather providers, structured error responses with type classification, auto-fetch on stale dashboard data, and improved error UX with specific messages.
This commit is contained in:
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +38,8 @@ type forecastRequest struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Timezone string `json:"timezone"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWeatherForecast(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -50,13 +54,24 @@ func (s *Server) handleWeatherForecast(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var provider weather.Provider
|
||||
switch req.Provider {
|
||||
case "openweathermap":
|
||||
if req.APIKey == "" {
|
||||
jsonError(w, "API key required for OpenWeatherMap", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
provider = weather.NewOpenWeatherMap(req.APIKey, nil)
|
||||
default:
|
||||
provider = weather.NewOpenMeteo(nil)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider := weather.NewOpenMeteo(nil)
|
||||
resp, err := provider.FetchForecast(ctx, req.Lat, req.Lon, req.Timezone)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
classifyWeatherError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,7 +101,7 @@ func (s *Server) handleWeatherWarnings(w http.ResponseWriter, r *http.Request) {
|
||||
provider := weather.NewDWDWFS(nil)
|
||||
warnings, err := provider.FetchWarnings(ctx, req.Lat, req.Lon)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
classifyWeatherError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -340,3 +355,26 @@ func jsonError(w http.ResponseWriter, msg string, status int) {
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func jsonErrorTyped(w http.ResponseWriter, msg, errType string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg, "type": errType})
|
||||
}
|
||||
|
||||
func classifyWeatherError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
jsonErrorTyped(w, err.Error(), "timeout", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
if netErr.Timeout() {
|
||||
jsonErrorTyped(w, err.Error(), "timeout", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
jsonErrorTyped(w, err.Error(), "network", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
jsonErrorTyped(w, err.Error(), "upstream", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type DWDWFS struct {
|
||||
// NewDWDWFS creates a new DWD WFS warning provider.
|
||||
func NewDWDWFS(client *http.Client) *DWDWFS {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
client = newRetryClient()
|
||||
}
|
||||
return &DWDWFS{client: client, baseURL: dwdWFSBaseURL}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type OpenMeteo struct {
|
||||
// NewOpenMeteo creates a new Open-Meteo provider.
|
||||
func NewOpenMeteo(client *http.Client) *OpenMeteo {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
client = newRetryClient()
|
||||
}
|
||||
return &OpenMeteo{client: client, baseURL: openMeteoBaseURL}
|
||||
}
|
||||
|
||||
148
internal/weather/openweathermap.go
Normal file
148
internal/weather/openweathermap.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const owmBaseURL = "https://api.openweathermap.org/data/3.0/onecall"
|
||||
|
||||
// OpenWeatherMap implements Provider using the OpenWeatherMap One Call API 3.0.
|
||||
type OpenWeatherMap struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// NewOpenWeatherMap creates a new OpenWeatherMap provider.
|
||||
func NewOpenWeatherMap(apiKey string, client *http.Client) *OpenWeatherMap {
|
||||
if client == nil {
|
||||
client = newRetryClient()
|
||||
}
|
||||
return &OpenWeatherMap{client: client, baseURL: owmBaseURL, apiKey: apiKey}
|
||||
}
|
||||
|
||||
func (o *OpenWeatherMap) Name() string { return "openweathermap" }
|
||||
|
||||
type owmHourlyRain struct {
|
||||
OneH float64 `json:"1h"`
|
||||
}
|
||||
|
||||
type owmHourly struct {
|
||||
Dt int64 `json:"dt"`
|
||||
Temp float64 `json:"temp"`
|
||||
FeelsLike float64 `json:"feels_like"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
Humidity float64 `json:"humidity"`
|
||||
DewPoint float64 `json:"dew_point"`
|
||||
UVI float64 `json:"uvi"`
|
||||
Clouds float64 `json:"clouds"`
|
||||
WindSpeed float64 `json:"wind_speed"`
|
||||
WindDeg float64 `json:"wind_deg"`
|
||||
Rain *owmHourlyRain `json:"rain,omitempty"`
|
||||
}
|
||||
|
||||
type owmDailyTemp struct {
|
||||
Day float64 `json:"day"`
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
}
|
||||
|
||||
type owmDailyFeelsLike struct {
|
||||
Day float64 `json:"day"`
|
||||
Night float64 `json:"night"`
|
||||
Eve float64 `json:"eve"`
|
||||
Morn float64 `json:"morn"`
|
||||
}
|
||||
|
||||
type owmDaily struct {
|
||||
Dt int64 `json:"dt"`
|
||||
Sunrise int64 `json:"sunrise"`
|
||||
Sunset int64 `json:"sunset"`
|
||||
Temp owmDailyTemp `json:"temp"`
|
||||
FeelsLike owmDailyFeelsLike `json:"feels_like"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
Humidity float64 `json:"humidity"`
|
||||
DewPoint float64 `json:"dew_point"`
|
||||
Clouds float64 `json:"clouds"`
|
||||
WindSpeed float64 `json:"wind_speed"`
|
||||
WindDeg float64 `json:"wind_deg"`
|
||||
UVI float64 `json:"uvi"`
|
||||
}
|
||||
|
||||
type owmResponse struct {
|
||||
Hourly []owmHourly `json:"hourly"`
|
||||
Daily []owmDaily `json:"daily"`
|
||||
}
|
||||
|
||||
func (o *OpenWeatherMap) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
|
||||
if o.apiKey == "" {
|
||||
return nil, fmt.Errorf("openweathermap: API key required")
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s?lat=%.4f&lon=%.4f&appid=%s&units=metric&exclude=minutely,alerts",
|
||||
o.baseURL, lat, lon, o.apiKey)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch openweathermap: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("openweathermap returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw owmResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode openweathermap: %w", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(timezone)
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
result := &ForecastResponse{Source: "openweathermap"}
|
||||
|
||||
for _, h := range raw.Hourly {
|
||||
ts := time.Unix(h.Dt, 0).In(loc)
|
||||
hf := HourlyForecast{
|
||||
Timestamp: ts,
|
||||
TemperatureC: h.Temp,
|
||||
ApparentTempC: h.FeelsLike,
|
||||
HumidityPct: h.Humidity,
|
||||
DewPointC: h.DewPoint,
|
||||
CloudCoverPct: h.Clouds,
|
||||
WindSpeedMs: h.WindSpeed,
|
||||
WindDirectionDeg: h.WindDeg,
|
||||
PressureHpa: h.Pressure,
|
||||
SunshineMin: (1 - h.Clouds/100) * 60,
|
||||
}
|
||||
if h.Rain != nil {
|
||||
hf.PrecipitationMm = h.Rain.OneH
|
||||
}
|
||||
result.Hourly = append(result.Hourly, hf)
|
||||
}
|
||||
|
||||
for _, d := range raw.Daily {
|
||||
result.Daily = append(result.Daily, DailyForecast{
|
||||
Date: time.Unix(d.Dt, 0).In(loc),
|
||||
TempMaxC: d.Temp.Max,
|
||||
TempMinC: d.Temp.Min,
|
||||
ApparentTempMaxC: d.FeelsLike.Day,
|
||||
Sunrise: time.Unix(d.Sunrise, 0).In(loc),
|
||||
Sunset: time.Unix(d.Sunset, 0).In(loc),
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
206
internal/weather/openweathermap_test.go
Normal file
206
internal/weather/openweathermap_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const owmTestJSON = `{
|
||||
"lat": 52.52,
|
||||
"lon": 13.41,
|
||||
"timezone": "Europe/Berlin",
|
||||
"hourly": [
|
||||
{
|
||||
"dt": 1752616800,
|
||||
"temp": 28.5,
|
||||
"feels_like": 30.1,
|
||||
"pressure": 1015,
|
||||
"humidity": 55,
|
||||
"dew_point": 18.5,
|
||||
"uvi": 7.2,
|
||||
"clouds": 25,
|
||||
"wind_speed": 4.2,
|
||||
"wind_deg": 210,
|
||||
"rain": {"1h": 0.5}
|
||||
},
|
||||
{
|
||||
"dt": 1752620400,
|
||||
"temp": 29.3,
|
||||
"feels_like": 31.0,
|
||||
"pressure": 1014,
|
||||
"humidity": 52,
|
||||
"dew_point": 18.0,
|
||||
"uvi": 8.1,
|
||||
"clouds": 10,
|
||||
"wind_speed": 3.8,
|
||||
"wind_deg": 220
|
||||
}
|
||||
],
|
||||
"daily": [
|
||||
{
|
||||
"dt": 1752616800,
|
||||
"sunrise": 1752544500,
|
||||
"sunset": 1752601500,
|
||||
"temp": {"day": 28.5, "min": 18.2, "max": 32.1},
|
||||
"feels_like": {"day": 30.1, "night": 19.5, "eve": 28.0, "morn": 20.0},
|
||||
"pressure": 1015,
|
||||
"humidity": 55,
|
||||
"dew_point": 18.5,
|
||||
"clouds": 25,
|
||||
"wind_speed": 4.2,
|
||||
"wind_deg": 210,
|
||||
"uvi": 8.1
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestOpenWeatherMapFetchForecast(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify query params
|
||||
q := r.URL.Query()
|
||||
if q.Get("appid") != "test-key" {
|
||||
t.Errorf("missing or wrong appid: %s", q.Get("appid"))
|
||||
}
|
||||
if q.Get("units") != "metric" {
|
||||
t.Errorf("expected units=metric, got %s", q.Get("units"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(owmTestJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
owm := NewOpenWeatherMap("test-key", srv.Client())
|
||||
owm.baseURL = srv.URL
|
||||
|
||||
resp, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchForecast: %v", err)
|
||||
}
|
||||
|
||||
if resp.Source != "openweathermap" {
|
||||
t.Errorf("Source = %s, want openweathermap", resp.Source)
|
||||
}
|
||||
|
||||
// Hourly
|
||||
if len(resp.Hourly) != 2 {
|
||||
t.Fatalf("Hourly len = %d, want 2", len(resp.Hourly))
|
||||
}
|
||||
h0 := resp.Hourly[0]
|
||||
if h0.TemperatureC != 28.5 {
|
||||
t.Errorf("Hourly[0].TemperatureC = %v, want 28.5", h0.TemperatureC)
|
||||
}
|
||||
if h0.ApparentTempC != 30.1 {
|
||||
t.Errorf("Hourly[0].ApparentTempC = %v, want 30.1", h0.ApparentTempC)
|
||||
}
|
||||
if h0.HumidityPct != 55 {
|
||||
t.Errorf("Hourly[0].HumidityPct = %v, want 55", h0.HumidityPct)
|
||||
}
|
||||
if h0.DewPointC != 18.5 {
|
||||
t.Errorf("Hourly[0].DewPointC = %v, want 18.5", h0.DewPointC)
|
||||
}
|
||||
if h0.CloudCoverPct != 25 {
|
||||
t.Errorf("Hourly[0].CloudCoverPct = %v, want 25", h0.CloudCoverPct)
|
||||
}
|
||||
if h0.WindSpeedMs != 4.2 {
|
||||
t.Errorf("Hourly[0].WindSpeedMs = %v, want 4.2", h0.WindSpeedMs)
|
||||
}
|
||||
if h0.WindDirectionDeg != 210 {
|
||||
t.Errorf("Hourly[0].WindDirectionDeg = %v, want 210", h0.WindDirectionDeg)
|
||||
}
|
||||
if h0.PrecipitationMm != 0.5 {
|
||||
t.Errorf("Hourly[0].PrecipitationMm = %v, want 0.5", h0.PrecipitationMm)
|
||||
}
|
||||
if h0.PressureHpa != 1015 {
|
||||
t.Errorf("Hourly[0].PressureHpa = %v, want 1015", h0.PressureHpa)
|
||||
}
|
||||
// Sunshine derived from cloud cover: (1 - 25/100) * 60 = 45
|
||||
if h0.SunshineMin != 45 {
|
||||
t.Errorf("Hourly[0].SunshineMin = %v, want 45", h0.SunshineMin)
|
||||
}
|
||||
|
||||
// Second hour: no rain field
|
||||
h1 := resp.Hourly[1]
|
||||
if h1.PrecipitationMm != 0 {
|
||||
t.Errorf("Hourly[1].PrecipitationMm = %v, want 0 (missing field)", h1.PrecipitationMm)
|
||||
}
|
||||
|
||||
// Daily
|
||||
if len(resp.Daily) != 1 {
|
||||
t.Fatalf("Daily len = %d, want 1", len(resp.Daily))
|
||||
}
|
||||
d0 := resp.Daily[0]
|
||||
if d0.TempMaxC != 32.1 {
|
||||
t.Errorf("Daily[0].TempMaxC = %v, want 32.1", d0.TempMaxC)
|
||||
}
|
||||
if d0.TempMinC != 18.2 {
|
||||
t.Errorf("Daily[0].TempMinC = %v, want 18.2", d0.TempMinC)
|
||||
}
|
||||
if d0.ApparentTempMaxC != 30.1 {
|
||||
t.Errorf("Daily[0].ApparentTempMaxC = %v, want 30.1", d0.ApparentTempMaxC)
|
||||
}
|
||||
if d0.Sunrise.IsZero() {
|
||||
t.Error("Daily[0].Sunrise is zero")
|
||||
}
|
||||
if d0.Sunset.IsZero() {
|
||||
t.Error("Daily[0].Sunset is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWeatherMapMissingAPIKey(t *testing.T) {
|
||||
owm := NewOpenWeatherMap("", nil)
|
||||
_, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWeatherMapUnauthorized(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"cod":401,"message":"Invalid API key"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
owm := NewOpenWeatherMap("bad-key", srv.Client())
|
||||
owm.baseURL = srv.URL
|
||||
|
||||
_, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err == nil {
|
||||
t.Error("expected error for 401 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWeatherMapMissingOptionalFields(t *testing.T) {
|
||||
// Minimal response: hourly without rain, uvi, etc.
|
||||
minimalJSON := `{
|
||||
"hourly": [{"dt": 1752616800, "temp": 20.0, "humidity": 60, "clouds": 50, "wind_speed": 2.0, "wind_deg": 180, "pressure": 1010}],
|
||||
"daily": [{"dt": 1752616800, "temp": {"max": 25.0, "min": 15.0}, "sunrise": 1752544500, "sunset": 1752601500}]
|
||||
}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(minimalJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
owm := NewOpenWeatherMap("key", srv.Client())
|
||||
owm.baseURL = srv.URL
|
||||
|
||||
resp, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchForecast: %v", err)
|
||||
}
|
||||
if len(resp.Hourly) != 1 {
|
||||
t.Fatalf("Hourly len = %d, want 1", len(resp.Hourly))
|
||||
}
|
||||
if resp.Hourly[0].TemperatureC != 20.0 {
|
||||
t.Errorf("TemperatureC = %v, want 20.0", resp.Hourly[0].TemperatureC)
|
||||
}
|
||||
if resp.Hourly[0].PrecipitationMm != 0 {
|
||||
t.Errorf("PrecipitationMm = %v, want 0", resp.Hourly[0].PrecipitationMm)
|
||||
}
|
||||
if resp.Hourly[0].DewPointC != 0 {
|
||||
t.Errorf("DewPointC = %v, want 0", resp.Hourly[0].DewPointC)
|
||||
}
|
||||
}
|
||||
88
internal/weather/retry.go
Normal file
88
internal/weather/retry.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryTransport wraps an http.RoundTripper with exponential backoff retries.
|
||||
type RetryTransport struct {
|
||||
Base http.RoundTripper
|
||||
MaxRetries int
|
||||
BaseDelay time.Duration
|
||||
}
|
||||
|
||||
func (rt *RetryTransport) base() http.RoundTripper {
|
||||
if rt.Base != nil {
|
||||
return rt.Base
|
||||
}
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
func (rt *RetryTransport) maxRetries() int {
|
||||
if rt.MaxRetries > 0 {
|
||||
return rt.MaxRetries
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func (rt *RetryTransport) baseDelay() time.Duration {
|
||||
if rt.BaseDelay > 0 {
|
||||
return rt.BaseDelay
|
||||
}
|
||||
return 500 * time.Millisecond
|
||||
}
|
||||
|
||||
func (rt *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
for attempt := 0; attempt <= rt.maxRetries(); attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := rt.baseDelay() * (1 << (attempt - 1))
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-req.Context().Done():
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
}
|
||||
|
||||
resp, err = rt.base().RoundTrip(req)
|
||||
if err != nil {
|
||||
// Network error — retry
|
||||
continue
|
||||
}
|
||||
|
||||
if !shouldRetry(resp.StatusCode) || attempt == rt.maxRetries() {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Drain body before retry to reuse connection
|
||||
if resp.Body != nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func shouldRetry(statusCode int) bool {
|
||||
if statusCode == 429 {
|
||||
return true
|
||||
}
|
||||
return statusCode >= 500
|
||||
}
|
||||
|
||||
// newRetryClient creates an http.Client with retry transport for provider use.
|
||||
func newRetryClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &RetryTransport{
|
||||
Base: http.DefaultTransport,
|
||||
MaxRetries: 3,
|
||||
BaseDelay: 500 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
}
|
||||
203
internal/weather/retry_test.go
Normal file
203
internal/weather/retry_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRetryOn502(t *testing.T) {
|
||||
var attempts int32
|
||||
transport := &retryCountTransport{
|
||||
attempts: &attempts,
|
||||
responses: []int{502, 502, 200},
|
||||
}
|
||||
rt := &RetryTransport{
|
||||
Base: transport,
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
}
|
||||
client := &http.Client{Transport: rt}
|
||||
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&attempts) != 3 {
|
||||
t.Errorf("attempts = %d, want 3", atomic.LoadInt32(&attempts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryOnNetworkError(t *testing.T) {
|
||||
var attempts int32
|
||||
transport := &retryErrorTransport{
|
||||
attempts: &attempts,
|
||||
failCount: 2,
|
||||
successResp: 200,
|
||||
}
|
||||
rt := &RetryTransport{
|
||||
Base: transport,
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
}
|
||||
client := &http.Client{Transport: rt}
|
||||
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&attempts) != 3 {
|
||||
t.Errorf("attempts = %d, want 3", atomic.LoadInt32(&attempts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryMaxRetriesExhausted(t *testing.T) {
|
||||
var attempts int32
|
||||
transport := &retryCountTransport{
|
||||
attempts: &attempts,
|
||||
responses: []int{502, 502, 502, 502},
|
||||
}
|
||||
rt := &RetryTransport{
|
||||
Base: transport,
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
}
|
||||
client := &http.Client{Transport: rt}
|
||||
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Should return the last 502
|
||||
if resp.StatusCode != 502 {
|
||||
t.Errorf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&attempts) != 4 { // 1 initial + 3 retries
|
||||
t.Errorf("attempts = %d, want 4", atomic.LoadInt32(&attempts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRetryOn400(t *testing.T) {
|
||||
var attempts int32
|
||||
transport := &retryCountTransport{
|
||||
attempts: &attempts,
|
||||
responses: []int{400},
|
||||
}
|
||||
rt := &RetryTransport{
|
||||
Base: transport,
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
}
|
||||
client := &http.Client{Transport: rt}
|
||||
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&attempts) != 1 {
|
||||
t.Errorf("attempts = %d, want 1 (no retry on 400)", atomic.LoadInt32(&attempts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryOn429(t *testing.T) {
|
||||
var attempts int32
|
||||
transport := &retryCountTransport{
|
||||
attempts: &attempts,
|
||||
responses: []int{429, 200},
|
||||
}
|
||||
rt := &RetryTransport{
|
||||
Base: transport,
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
}
|
||||
client := &http.Client{Transport: rt}
|
||||
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if atomic.LoadInt32(&attempts) != 2 {
|
||||
t.Errorf("attempts = %d, want 2", atomic.LoadInt32(&attempts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryRespectsContextCancellation(t *testing.T) {
|
||||
var attempts int32
|
||||
transport := &retryCountTransport{
|
||||
attempts: &attempts,
|
||||
responses: []int{502, 502, 502},
|
||||
}
|
||||
rt := &RetryTransport{
|
||||
Base: transport,
|
||||
MaxRetries: 3,
|
||||
BaseDelay: 50 * time.Millisecond,
|
||||
}
|
||||
client := &http.Client{Transport: rt}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", "http://example.com", nil)
|
||||
_, err := client.Do(req)
|
||||
if err == nil {
|
||||
t.Error("expected error from cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
|
||||
type retryCountTransport struct {
|
||||
attempts *int32
|
||||
responses []int
|
||||
}
|
||||
|
||||
func (t *retryCountTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
idx := int(atomic.AddInt32(t.attempts, 1)) - 1
|
||||
if idx >= len(t.responses) {
|
||||
idx = len(t.responses) - 1
|
||||
}
|
||||
code := t.responses[idx]
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type retryErrorTransport struct {
|
||||
attempts *int32
|
||||
failCount int
|
||||
successResp int
|
||||
}
|
||||
|
||||
func (t *retryErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
idx := int(atomic.AddInt32(t.attempts, 1)) - 1
|
||||
if idx < t.failCount {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: t.successResp,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
@@ -145,6 +145,11 @@
|
||||
"forecast": {
|
||||
"title": "Wettervorhersage",
|
||||
"help": "Wetterdaten f\u00fcr Ihren Profilstandort abrufen.",
|
||||
"provider": "Wetteranbieter",
|
||||
"free": "kostenlos, kein Schl\u00fcssel n\u00f6tig",
|
||||
"apiKey": "API-Schl\u00fcssel",
|
||||
"apiKeyPlaceholder": "OpenWeatherMap API-Schl\u00fcssel eingeben",
|
||||
"saveConfig": "Einstellungen speichern",
|
||||
"fetch": "Vorhersage abrufen",
|
||||
"lastFetched": "Zuletzt abgerufen",
|
||||
"never": "Nie",
|
||||
@@ -194,6 +199,13 @@
|
||||
"loading": "Laden\u2026",
|
||||
"computing": "Hitzeanalyse wird berechnet\u2026",
|
||||
"error": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
|
||||
"retry": "Erneut versuchen",
|
||||
"staleDataWarning": "Zwischengespeicherte Vorhersagedaten werden verwendet. Letzter Abruf fehlgeschlagen.",
|
||||
"errorTimeout": "Wetterdienst hat nicht rechtzeitig geantwortet. Versuchen Sie es gleich erneut.",
|
||||
"errorNetwork": "Wetterdienst nicht erreichbar. Pr\u00fcfen Sie Ihre Verbindung.",
|
||||
"errorUpstream": "Wetterdienst hat einen Fehler zur\u00fcckgegeben. Versuchen Sie es sp\u00e4ter erneut.",
|
||||
"errorUnknown": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||
"autoFetching": "Vorhersage wird aktualisiert\u2026",
|
||||
"internalGains": "Interne Gewinne",
|
||||
"solarGain": "Solargewinn",
|
||||
"ventGain": "L\u00fcftungsgewinn",
|
||||
|
||||
@@ -145,6 +145,11 @@
|
||||
"forecast": {
|
||||
"title": "Forecast",
|
||||
"help": "Fetch weather forecast data for your profile location.",
|
||||
"provider": "Weather Provider",
|
||||
"free": "free, no key needed",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyPlaceholder": "Enter OpenWeatherMap API key",
|
||||
"saveConfig": "Save Settings",
|
||||
"fetch": "Fetch Forecast",
|
||||
"lastFetched": "Last fetched",
|
||||
"never": "Never",
|
||||
@@ -194,6 +199,13 @@
|
||||
"loading": "Loading\u2026",
|
||||
"computing": "Computing heat analysis\u2026",
|
||||
"error": "Failed to load data. Please try again.",
|
||||
"retry": "Retry",
|
||||
"staleDataWarning": "Using cached forecast data. Latest fetch failed.",
|
||||
"errorTimeout": "Weather service timed out. Try again in a moment.",
|
||||
"errorNetwork": "Could not reach weather service. Check your connection.",
|
||||
"errorUpstream": "Weather service returned an error. Try again later.",
|
||||
"errorUnknown": "Something went wrong. Please try again.",
|
||||
"autoFetching": "Updating forecast\u2026",
|
||||
"internalGains": "Internal Gains",
|
||||
"solarGain": "Solar Gain",
|
||||
"ventGain": "Ventilation Gain",
|
||||
|
||||
@@ -84,10 +84,28 @@
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
const STALE_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
|
||||
function displayDetailedError(err) {
|
||||
const ts = t();
|
||||
const msgEl = $("error-message");
|
||||
const hintEl = $("error-hint");
|
||||
const typeMap = {
|
||||
timeout: ts.errorTimeout || "Weather service timed out.",
|
||||
network: ts.errorNetwork || "Could not reach weather service.",
|
||||
upstream: ts.errorUpstream || "Weather service returned an error.",
|
||||
};
|
||||
const msg = (err && err.type && typeMap[err.type]) || ts.errorUnknown || "Something went wrong.";
|
||||
if (msgEl) msgEl.textContent = msg;
|
||||
if (hintEl) hintEl.textContent = err && err.message && err.message !== msg ? err.message : "";
|
||||
}
|
||||
|
||||
window.loadDashboard = async function() {
|
||||
hide("no-data");
|
||||
hide("no-forecast");
|
||||
hide("error-state");
|
||||
hide("stale-warning");
|
||||
hide("auto-fetch-indicator");
|
||||
hide("data-display");
|
||||
show("loading");
|
||||
|
||||
@@ -99,7 +117,41 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
|
||||
let forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
|
||||
|
||||
// Auto-fetch if data is stale or missing
|
||||
const lastFetched = await getSetting("lastFetched");
|
||||
const isStale = !lastFetched || (Date.now() - new Date(lastFetched).getTime()) > STALE_THRESHOLD_MS;
|
||||
|
||||
if (isStale) {
|
||||
if (forecasts.length > 0) {
|
||||
// Have old data — auto-fetch silently in background
|
||||
show("auto-fetch-indicator");
|
||||
try {
|
||||
await fetchForecastForProfile(profileId);
|
||||
forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
|
||||
} catch (fetchErr) {
|
||||
// Old data exists — show stale warning, continue with old data
|
||||
show("stale-warning");
|
||||
}
|
||||
hide("auto-fetch-indicator");
|
||||
} else {
|
||||
// No data at all — try to fetch
|
||||
show("auto-fetch-indicator");
|
||||
try {
|
||||
await fetchForecastForProfile(profileId);
|
||||
forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
|
||||
} catch (fetchErr) {
|
||||
hide("loading");
|
||||
hide("auto-fetch-indicator");
|
||||
displayDetailedError(fetchErr);
|
||||
show("error-state");
|
||||
return;
|
||||
}
|
||||
hide("auto-fetch-indicator");
|
||||
}
|
||||
}
|
||||
|
||||
if (forecasts.length === 0) {
|
||||
hide("loading");
|
||||
show("no-forecast");
|
||||
@@ -239,6 +291,7 @@
|
||||
|
||||
} catch (err) {
|
||||
hide("loading");
|
||||
displayDetailedError(err);
|
||||
show("error-state");
|
||||
console.error("Dashboard error:", err);
|
||||
}
|
||||
@@ -813,6 +866,9 @@
|
||||
const refreshBtn = $("refresh-forecast-btn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", refreshForecast);
|
||||
|
||||
const staleRetryBtn = $("stale-retry-btn");
|
||||
if (staleRetryBtn) staleRetryBtn.addEventListener("click", refreshForecast);
|
||||
|
||||
// ========== Profile Switcher ==========
|
||||
let _profileSwitcherInit = false;
|
||||
async function initProfileSwitcher(activeId) {
|
||||
|
||||
33
web/js/db.js
33
web/js/db.js
@@ -210,17 +210,36 @@ async function fetchForecastForProfile(profileId) {
|
||||
const profile = profiles.find(p => p.id === profileId);
|
||||
if (!profile) throw new Error("Profile not found");
|
||||
|
||||
// Fetch forecast
|
||||
// Build forecast request with optional provider config
|
||||
const forecastBody = {
|
||||
lat: profile.latitude,
|
||||
lon: profile.longitude,
|
||||
timezone: profile.timezone || "Europe/Berlin",
|
||||
};
|
||||
const forecastProvider = await getSetting("forecastProvider");
|
||||
const forecastApiKey = await getSetting("forecastApiKey");
|
||||
if (forecastProvider) forecastBody.provider = forecastProvider;
|
||||
if (forecastApiKey) forecastBody.apiKey = forecastApiKey;
|
||||
|
||||
const resp = await fetch("/api/weather/forecast", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lat: profile.latitude,
|
||||
lon: profile.longitude,
|
||||
timezone: profile.timezone || "Europe/Berlin",
|
||||
}),
|
||||
body: JSON.stringify(forecastBody),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
if (!resp.ok) {
|
||||
let errMsg = "Forecast fetch failed";
|
||||
let errType = "unknown";
|
||||
try {
|
||||
const errData = await resp.json();
|
||||
errMsg = errData.error || errMsg;
|
||||
errType = errData.type || errType;
|
||||
} catch (_) {
|
||||
errMsg = await resp.text();
|
||||
}
|
||||
const err = new Error(errMsg);
|
||||
err.type = errType;
|
||||
throw err;
|
||||
}
|
||||
const data = await resp.json();
|
||||
|
||||
// Clear old forecasts and store new ones
|
||||
|
||||
@@ -984,6 +984,39 @@
|
||||
});
|
||||
|
||||
// ========== Forecast ==========
|
||||
async function loadForecastConfig() {
|
||||
const provider = await getSetting("forecastProvider");
|
||||
const apiKey = await getSetting("forecastApiKey");
|
||||
if (provider) document.getElementById("forecast-provider-select").value = provider;
|
||||
if (apiKey) document.getElementById("forecast-api-key").value = apiKey;
|
||||
toggleForecastApiKeyField(provider);
|
||||
}
|
||||
|
||||
function toggleForecastApiKeyField(provider) {
|
||||
const group = document.getElementById("forecast-apikey-group");
|
||||
const hint = document.getElementById("forecast-provider-hint");
|
||||
if (provider === "openweathermap") {
|
||||
group.classList.remove("hidden");
|
||||
hint.innerHTML = '<a href="https://openweathermap.org/api" target="_blank" rel="noopener" class="text-orange-600 hover:underline">Get an API key \u2192</a>';
|
||||
} else {
|
||||
group.classList.add("hidden");
|
||||
hint.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("forecast-provider-select").addEventListener("change", (e) => {
|
||||
toggleForecastApiKeyField(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("forecast-config-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const provider = document.getElementById("forecast-provider-select").value;
|
||||
const apiKey = document.getElementById("forecast-api-key").value;
|
||||
await setSetting("forecastProvider", provider);
|
||||
await setSetting("forecastApiKey", apiKey);
|
||||
showToast("Forecast settings saved", false);
|
||||
});
|
||||
|
||||
document.getElementById("fetch-forecast-btn").addEventListener("click", async () => {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) { showToast("Select a profile first", true); return; }
|
||||
@@ -1151,6 +1184,7 @@
|
||||
await loadOccupants();
|
||||
await loadACUnits();
|
||||
await loadToggles();
|
||||
await loadForecastConfig();
|
||||
await loadForecastStatus();
|
||||
await loadLLMConfig();
|
||||
await refreshRoomSelects();
|
||||
|
||||
@@ -33,11 +33,23 @@
|
||||
<!-- Error state -->
|
||||
<div id="error-state" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<p class="text-red-600 dark:text-red-400 mb-4">{{t "dashboard.error"}}</p>
|
||||
<button onclick="loadDashboard()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">↻</button>
|
||||
<p id="error-message" class="text-red-600 dark:text-red-400 mb-2">{{t "dashboard.error"}}</p>
|
||||
<p id="error-hint" class="text-sm text-gray-500 dark:text-gray-400 mb-4"></p>
|
||||
<button onclick="loadDashboard()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.retry"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stale data warning banner -->
|
||||
<div id="stale-warning" class="hidden bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg px-4 py-3 mb-4 flex items-center justify-between">
|
||||
<span class="text-sm text-yellow-700 dark:text-yellow-300">{{t "dashboard.staleDataWarning"}}</span>
|
||||
<button id="stale-retry-btn" type="button" class="px-3 py-1 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition">{{t "dashboard.retry"}}</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-fetch indicator -->
|
||||
<div id="auto-fetch-indicator" class="hidden text-center py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="inline-block animate-spin mr-1">↻</span> {{t "dashboard.autoFetching"}}
|
||||
</div>
|
||||
|
||||
<!-- Data display -->
|
||||
<div id="data-display" class="hidden space-y-5">
|
||||
<!-- Header -->
|
||||
@@ -290,6 +302,10 @@
|
||||
legendTemp: "{{t "dashboard.legendTemp"}}",
|
||||
legendCooling: "{{t "dashboard.legendCooling"}}",
|
||||
legendAI: "{{t "dashboard.legendAI"}}",
|
||||
errorTimeout: "{{t "dashboard.errorTimeout"}}",
|
||||
errorNetwork: "{{t "dashboard.errorNetwork"}}",
|
||||
errorUpstream: "{{t "dashboard.errorUpstream"}}",
|
||||
errorUnknown: "{{t "dashboard.errorUnknown"}}",
|
||||
category: {
|
||||
shading: "{{t "dashboard.category.shading"}}",
|
||||
ventilation: "{{t "dashboard.category.ventilation"}}",
|
||||
|
||||
@@ -375,7 +375,29 @@
|
||||
<!-- Forecast tab -->
|
||||
<section id="tab-forecast" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.forecast.help"}}</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||
<!-- Provider config -->
|
||||
<form id="forecast-config-form" class="space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.forecast.provider"}}</label>
|
||||
<select id="forecast-provider-select" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="openmeteo">Open-Meteo ({{t "setup.forecast.free"}})</option>
|
||||
<option value="openweathermap">OpenWeatherMap</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="forecast-apikey-group" class="hidden">
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.forecast.apiKey"}}</label>
|
||||
<input type="password" id="forecast-api-key" placeholder="{{t "setup.forecast.apiKeyPlaceholder"}}" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div id="forecast-provider-hint" class="text-xs text-gray-400"></div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.forecast.saveConfig"}}</button>
|
||||
</form>
|
||||
|
||||
<hr class="border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Fetch button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button id="fetch-forecast-btn" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition text-sm">
|
||||
{{t "setup.forecast.fetch"}}
|
||||
@@ -385,7 +407,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div id="forecast-spinner" class="hidden text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="inline-block animate-spin mr-2">↻</span> {{t "setup.forecast.fetching"}}
|
||||
<span class="inline-block animate-spin mr-2">↻</span> {{t "setup.forecast.fetching"}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user