add web ui
This commit is contained in:
@@ -28,6 +28,9 @@ COPY --from=build /out/ingestd /app/ingestd
|
||||
|
||||
USER appuser
|
||||
|
||||
# Web UI
|
||||
EXPOSE 8080
|
||||
|
||||
# default config path inside container
|
||||
ENTRYPOINT ["/app/ingestd"]
|
||||
CMD ["-config", "/app/config.yaml"]
|
||||
|
||||
@@ -15,5 +15,8 @@ Starter go-weatherstationrology data pipeline:
|
||||
3) Run:
|
||||
go run ./cmd/ingestd -config config.yaml
|
||||
|
||||
4) Web UI:
|
||||
http://localhost:8080
|
||||
|
||||
## Publish a test WS90 payload
|
||||
mosquitto_pub -h localhost -t ecowitt/ws90 -m '{"model":"Fineoffset-WS90","id":70618,"battery_ok":1,"battery_mV":3180,"temperature_C":24.2,"humidity":60,"wind_dir_deg":129,"wind_avg_m_s":0,"wind_max_m_s":0,"uvi":0,"light_lux":0,"flags":130,"rain_mm":0,"rain_start":0,"supercap_V":0.5,"firmware":160,"data":"3fff000000------0000ff7ff70000","mic":"CRC","protocol":"Fine Offset Electronics WS90 weather station","rssi":-44,"duration":32996}'
|
||||
|
||||
@@ -58,6 +58,14 @@ func main() {
|
||||
go runOpenMeteoPoller(ctx, d, forecastCache, site, cfg.Pollers.OpenMeteo.Model, cfg.Pollers.OpenMeteo.Interval)
|
||||
}
|
||||
|
||||
if cfg.Web.Enabled != nil && *cfg.Web.Enabled {
|
||||
go func() {
|
||||
if err := runWebServer(ctx, d, site, cfg.Pollers.OpenMeteo.Model, cfg.Web.Listen); err != nil {
|
||||
log.Printf("web server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if cfg.Wunderground.Enabled {
|
||||
go runWundergroundUploader(ctx, latest, cfg.Wunderground.StationID, cfg.Wunderground.StationKey, cfg.Wunderground.Interval)
|
||||
}
|
||||
|
||||
149
cmd/ingestd/web.go
Normal file
149
cmd/ingestd/web.go
Normal file
@@ -0,0 +1,149 @@
|
||||
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"`
|
||||
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"
|
||||
}
|
||||
bucket := r.URL.Query().Get("bucket")
|
||||
if bucket == "" {
|
||||
bucket = "5m"
|
||||
}
|
||||
if bucket != "1m" && bucket != "5m" {
|
||||
http.Error(w, "invalid bucket", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rangeDur, err := time.ParseDuration(rangeStr)
|
||||
if err != nil || rangeDur <= 0 {
|
||||
http.Error(w, "invalid range", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
observations, err := s.db.ObservationSeries(r.Context(), s.site.Name, bucket, int64(rangeDur.Seconds()))
|
||||
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,
|
||||
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)
|
||||
}
|
||||
}
|
||||
352
cmd/ingestd/web/app.js
Normal file
352
cmd/ingestd/web/app.js
Normal file
@@ -0,0 +1,352 @@
|
||||
const state = {
|
||||
range: "24h",
|
||||
bucket: "5m",
|
||||
charts: {},
|
||||
timer: null,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
obs: "#7bdff2",
|
||||
forecast: "#f4b942",
|
||||
gust: "#ff7d6b",
|
||||
humidity: "#7ee081",
|
||||
uvi: "#f4d35e",
|
||||
light: "#b8f2e6",
|
||||
precip: "#4ea8de",
|
||||
};
|
||||
|
||||
function formatNumber(value, digits) {
|
||||
if (value === null || value === undefined) {
|
||||
return "--";
|
||||
}
|
||||
return Number(value).toFixed(digits);
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return "--";
|
||||
const dt = new Date(iso);
|
||||
if (Number.isNaN(dt.getTime())) return "--";
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
|
||||
function series(points, key) {
|
||||
return points.map((p) => ({
|
||||
x: p.ts,
|
||||
y: p[key] === undefined ? null : p[key],
|
||||
}));
|
||||
}
|
||||
|
||||
function minMax(values) {
|
||||
let min = null;
|
||||
let max = null;
|
||||
for (const v of values) {
|
||||
if (v === null || v === undefined) continue;
|
||||
if (min === null || v < min) min = v;
|
||||
if (max === null || v > max) max = v;
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function sum(values) {
|
||||
let total = 0;
|
||||
let seen = false;
|
||||
for (const v of values) {
|
||||
if (v === null || v === undefined) continue;
|
||||
total += v;
|
||||
seen = true;
|
||||
}
|
||||
return seen ? total : null;
|
||||
}
|
||||
|
||||
function updateText(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function upsertChart(id, config) {
|
||||
const ctx = document.getElementById(id);
|
||||
if (!ctx) return;
|
||||
if (state.charts[id]) {
|
||||
state.charts[id].data = config.data;
|
||||
state.charts[id].options = config.options;
|
||||
state.charts[id].update();
|
||||
return;
|
||||
}
|
||||
state.charts[id] = new Chart(ctx, config);
|
||||
}
|
||||
|
||||
function baseOptions() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: "#d6f0f0" } },
|
||||
tooltip: { mode: "index", intersect: false },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: { unit: "hour" },
|
||||
ticks: { color: "#a4c4c4", maxTicksLimit: 6 },
|
||||
grid: { color: "rgba(123, 223, 242, 0.08)" },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: "#a4c4c4" },
|
||||
grid: { color: "rgba(123, 223, 242, 0.08)" },
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: { tension: 0.2, borderWidth: 2 },
|
||||
point: { radius: 0, hitRadius: 8 },
|
||||
},
|
||||
spanGaps: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDashboard(data) {
|
||||
const latest = data.latest;
|
||||
updateText("site-meta", `${data.site} | model ${data.model}`);
|
||||
updateText("last-updated", `updated ${formatTime(data.generated_at)}`);
|
||||
const forecastMeta = data.forecast && data.forecast.points && data.forecast.points.length
|
||||
? `forecast retrieved ${formatTime(data.forecast.retrieved_at)}`
|
||||
: "forecast not available";
|
||||
updateText("forecast-meta", forecastMeta);
|
||||
|
||||
if (latest) {
|
||||
updateText("live-temp", `${formatNumber(latest.temp_c, 2)} C`);
|
||||
updateText("live-rh", `${formatNumber(latest.rh, 0)} %`);
|
||||
updateText("live-wind", `${formatNumber(latest.wind_m_s, 2)}`);
|
||||
updateText("live-gust", `${formatNumber(latest.wind_gust_m_s, 2)}`);
|
||||
updateText("live-wdir", `${formatNumber(latest.wind_dir_deg, 0)}`);
|
||||
updateText("live-uvi", `${formatNumber(latest.uvi, 2)}`);
|
||||
updateText("live-lux", `${formatNumber(latest.light_lux, 0)}`);
|
||||
} else {
|
||||
updateText("live-temp", "--");
|
||||
updateText("live-rh", "--");
|
||||
updateText("live-wind", "--");
|
||||
updateText("live-gust", "--");
|
||||
updateText("live-wdir", "--");
|
||||
updateText("live-uvi", "--");
|
||||
updateText("live-lux", "--");
|
||||
}
|
||||
|
||||
const forecastUrl = document.getElementById("forecast-url");
|
||||
if (forecastUrl) {
|
||||
if (data.open_meteo_url) {
|
||||
forecastUrl.href = data.open_meteo_url;
|
||||
forecastUrl.textContent = data.open_meteo_url;
|
||||
} else {
|
||||
forecastUrl.textContent = "--";
|
||||
}
|
||||
}
|
||||
|
||||
const obs = data.observations || [];
|
||||
const forecast = (data.forecast && data.forecast.points) || [];
|
||||
|
||||
const obsTemps = obs.map((p) => p.temp_c);
|
||||
const obsWinds = obs.map((p) => p.wind_m_s);
|
||||
const obsGusts = obs.map((p) => p.wind_gust_m_s);
|
||||
const obsRH = obs.map((p) => p.rh);
|
||||
const obsUvi = obs.map((p) => p.uvi);
|
||||
const obsLux = obs.map((p) => p.light_lux);
|
||||
|
||||
const fcTemps = forecast.map((p) => p.temp_c);
|
||||
const fcWinds = forecast.map((p) => p.wind_m_s);
|
||||
const fcGusts = forecast.map((p) => p.wind_gust_m_s);
|
||||
const fcRH = forecast.map((p) => p.rh);
|
||||
const fcPrecip = forecast.map((p) => p.precip_mm);
|
||||
|
||||
const obsTempSummary = minMax(obsTemps);
|
||||
const obsWindSummary = minMax(obsWinds);
|
||||
const obsUviSummary = minMax(obsUvi);
|
||||
const obsLuxSummary = minMax(obsLux);
|
||||
|
||||
const forecastTempSummary = minMax(fcTemps);
|
||||
const forecastPrecipTotal = sum(fcPrecip);
|
||||
|
||||
const obsParts = [];
|
||||
if (obsTempSummary.min !== null) {
|
||||
obsParts.push(`temp_c ${obsTempSummary.min.toFixed(1)} to ${obsTempSummary.max.toFixed(1)}`);
|
||||
}
|
||||
if (obsWindSummary.max !== null) {
|
||||
obsParts.push(`wind_max ${obsWindSummary.max.toFixed(1)} m/s`);
|
||||
}
|
||||
if (obsUviSummary.max !== null) {
|
||||
obsParts.push(`uvi_max ${obsUviSummary.max.toFixed(1)}`);
|
||||
}
|
||||
if (obsLuxSummary.max !== null) {
|
||||
obsParts.push(`lux_max ${obsLuxSummary.max.toFixed(0)}`);
|
||||
}
|
||||
updateText("obs-summary", obsParts.length ? obsParts.join(" | ") : "--");
|
||||
|
||||
const forecastParts = [];
|
||||
if (forecastTempSummary.min !== null) {
|
||||
forecastParts.push(`temp_c ${forecastTempSummary.min.toFixed(1)} to ${forecastTempSummary.max.toFixed(1)}`);
|
||||
}
|
||||
if (forecastPrecipTotal !== null) {
|
||||
forecastParts.push(`precip_total ${forecastPrecipTotal.toFixed(1)} mm`);
|
||||
}
|
||||
updateText("forecast-summary", forecastParts.length ? forecastParts.join(" | ") : "--");
|
||||
|
||||
updateText("commentary", buildCommentary(latest, forecast));
|
||||
|
||||
const tempChart = {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "obs temp C", data: series(obs, "temp_c"), borderColor: colors.obs },
|
||||
{ label: "forecast temp C", data: series(forecast, "temp_c"), borderColor: colors.forecast },
|
||||
],
|
||||
},
|
||||
options: baseOptions(),
|
||||
};
|
||||
upsertChart("chart-temp", tempChart);
|
||||
|
||||
const windChart = {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "obs wind m/s", data: series(obs, "wind_m_s"), borderColor: colors.obs },
|
||||
{ label: "obs gust m/s", data: series(obs, "wind_gust_m_s"), borderColor: colors.gust },
|
||||
{ label: "forecast wind m/s", data: series(forecast, "wind_m_s"), borderColor: colors.forecast },
|
||||
{ label: "forecast gust m/s", data: series(forecast, "wind_gust_m_s"), borderColor: "#f7d79f" },
|
||||
],
|
||||
},
|
||||
options: baseOptions(),
|
||||
};
|
||||
upsertChart("chart-wind", windChart);
|
||||
|
||||
const rhChart = {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "obs humidity %", data: series(obs, "rh"), borderColor: colors.humidity },
|
||||
{ label: "forecast rh %", data: series(forecast, "rh"), borderColor: colors.forecast },
|
||||
],
|
||||
},
|
||||
options: baseOptions(),
|
||||
};
|
||||
upsertChart("chart-rh", rhChart);
|
||||
|
||||
const lightOptions = baseOptions();
|
||||
lightOptions.scales.y1 = {
|
||||
position: "right",
|
||||
ticks: { color: "#a4c4c4" },
|
||||
grid: { drawOnChartArea: false },
|
||||
};
|
||||
|
||||
const lightChart = {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "uvi", data: series(obs, "uvi"), borderColor: colors.uvi, yAxisID: "y" },
|
||||
{ label: "light lux", data: series(obs, "light_lux"), borderColor: colors.light, yAxisID: "y1" },
|
||||
],
|
||||
},
|
||||
options: lightOptions,
|
||||
};
|
||||
upsertChart("chart-light", lightChart);
|
||||
|
||||
const precipChart = {
|
||||
type: "bar",
|
||||
data: {
|
||||
datasets: [
|
||||
{ label: "forecast precip mm", data: series(forecast, "precip_mm"), backgroundColor: colors.precip },
|
||||
],
|
||||
},
|
||||
options: baseOptions(),
|
||||
};
|
||||
upsertChart("chart-precip", precipChart);
|
||||
}
|
||||
|
||||
function buildCommentary(latest, forecast) {
|
||||
if (!latest || !forecast || !forecast.length) {
|
||||
return "Waiting for forecast data...";
|
||||
}
|
||||
|
||||
const obsTs = new Date(latest.ts);
|
||||
if (Number.isNaN(obsTs.getTime())) {
|
||||
return "No valid observation timestamp yet.";
|
||||
}
|
||||
|
||||
let nearest = null;
|
||||
let bestDiff = null;
|
||||
for (const p of forecast) {
|
||||
if (!p.ts) continue;
|
||||
const fcTs = new Date(p.ts);
|
||||
if (Number.isNaN(fcTs.getTime())) continue;
|
||||
const diff = Math.abs(obsTs - fcTs);
|
||||
if (bestDiff === null || diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
nearest = p;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nearest || bestDiff > 2 * 60 * 60 * 1000) {
|
||||
return "No nearby forecast point to compare yet.";
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (latest.temp_c !== null && nearest.temp_c !== null) {
|
||||
const delta = latest.temp_c - nearest.temp_c;
|
||||
parts.push(`temp ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} C`);
|
||||
}
|
||||
if (latest.wind_m_s !== null && nearest.wind_m_s !== null) {
|
||||
const delta = latest.wind_m_s - nearest.wind_m_s;
|
||||
parts.push(`wind ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`);
|
||||
}
|
||||
if (latest.wind_gust_m_s !== null && nearest.wind_gust_m_s !== null) {
|
||||
const delta = latest.wind_gust_m_s - nearest.wind_gust_m_s;
|
||||
parts.push(`gust ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`);
|
||||
}
|
||||
if (latest.rh !== null && nearest.rh !== null) {
|
||||
const delta = latest.rh - nearest.rh;
|
||||
parts.push(`rh ${delta >= 0 ? "+" : ""}${delta.toFixed(0)} %`);
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return "Not enough data to compute deviation yet.";
|
||||
}
|
||||
|
||||
return `Now vs forecast: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
async function loadAndRender() {
|
||||
const params = new URLSearchParams({
|
||||
range: state.range,
|
||||
bucket: state.bucket,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`/api/dashboard?${params.toString()}`, { cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
updateText("commentary", `Dashboard error ${resp.status}`);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
renderDashboard(data);
|
||||
} catch (err) {
|
||||
updateText("commentary", "Dashboard fetch failed.");
|
||||
}
|
||||
}
|
||||
|
||||
function setupControls() {
|
||||
const buttons = document.querySelectorAll(".btn[data-range]");
|
||||
buttons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
buttons.forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
state.range = btn.dataset.range;
|
||||
state.bucket = state.range === "6h" ? "1m" : "5m";
|
||||
loadAndRender();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setupControls();
|
||||
loadAndRender();
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
state.timer = setInterval(loadAndRender, 60 * 1000);
|
||||
});
|
||||
119
cmd/ingestd/web/index.html
Normal file
119
cmd/ingestd/web/index.html
Normal file
@@ -0,0 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Weatherstation Console</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg"></div>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="title">Weatherstation Console</div>
|
||||
<div class="subtitle" id="site-meta">Loading...</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="btn" data-range="6h">6h</button>
|
||||
<button class="btn active" data-range="24h">24h</button>
|
||||
<button class="btn" data-range="72h">72h</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel hero">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">Live Snapshot</div>
|
||||
<div class="panel-meta" id="last-updated">--</div>
|
||||
</div>
|
||||
<div class="live-grid">
|
||||
<div class="metric">
|
||||
<div class="label">Temp C</div>
|
||||
<div class="value" id="live-temp">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Humidity</div>
|
||||
<div class="value" id="live-rh">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Wind m/s</div>
|
||||
<div class="value" id="live-wind">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Gust m/s</div>
|
||||
<div class="value" id="live-gust">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Wind Dir deg</div>
|
||||
<div class="value" id="live-wdir">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">UVI</div>
|
||||
<div class="value" id="live-uvi">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Light Lux</div>
|
||||
<div class="value" id="live-lux">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callouts">
|
||||
<div class="callout">
|
||||
<div class="callout-title">Deviation Commentary</div>
|
||||
<div class="callout-body" id="commentary">Waiting for forecast data...</div>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<div class="callout-title">Observation Summary</div>
|
||||
<div class="callout-body" id="obs-summary">--</div>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<div class="callout-title">Forecast Summary</div>
|
||||
<div class="callout-body" id="forecast-summary">--</div>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<div class="callout-title">Forecast Request</div>
|
||||
<div class="callout-body">
|
||||
<a id="forecast-url" class="mono" href="#" target="_blank" rel="noreferrer">--</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel charts">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">Metrics</div>
|
||||
<div class="panel-meta" id="forecast-meta">--</div>
|
||||
</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Temperature (obs vs forecast)</div>
|
||||
<canvas id="chart-temp"></canvas>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Wind (obs vs forecast)</div>
|
||||
<canvas id="chart-wind"></canvas>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Humidity (obs vs forecast)</div>
|
||||
<canvas id="chart-rh"></canvas>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">UV Index and Light</div>
|
||||
<canvas id="chart-light"></canvas>
|
||||
</div>
|
||||
<div class="chart-card wide">
|
||||
<div class="chart-title">Precipitation (forecast)</div>
|
||||
<canvas id="chart-precip"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
249
cmd/ingestd/web/styles.css
Normal file
249
cmd/ingestd/web/styles.css
Normal file
@@ -0,0 +1,249 @@
|
||||
:root {
|
||||
--bg: #0e1a21;
|
||||
--bg-2: #162733;
|
||||
--card: rgba(20, 39, 49, 0.85);
|
||||
--line: rgba(123, 223, 242, 0.25);
|
||||
--text: #e4f2f2;
|
||||
--muted: #a4c4c4;
|
||||
--accent: #7bdff2;
|
||||
--accent-2: #f4b942;
|
||||
--accent-3: #ff7d6b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(700px circle at 10% 20%, rgba(123, 223, 242, 0.18), transparent 45%),
|
||||
radial-gradient(600px circle at 85% 15%, rgba(244, 185, 66, 0.2), transparent 40%),
|
||||
radial-gradient(900px circle at 30% 80%, rgba(255, 125, 107, 0.12), transparent 50%),
|
||||
linear-gradient(180deg, var(--bg), var(--bg-2));
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 28px 6vw 10px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(22px, 3vw, 30px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
background: rgba(123, 223, 242, 0.15);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.6fr);
|
||||
gap: 24px;
|
||||
padding: 10px 6vw 48px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(123, 223, 242, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 18px 40px rgba(5, 12, 16, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
animation: rise 0.6s ease both;
|
||||
}
|
||||
|
||||
.panel:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.live-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(123, 223, 242, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(16, 31, 39, 0.6);
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.metric .value {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 18px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.callouts {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
border: 1px solid rgba(123, 223, 242, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: rgba(14, 26, 33, 0.6);
|
||||
}
|
||||
|
||||
.callout-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.callout-body {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
color: var(--accent);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.charts {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: rgba(10, 20, 26, 0.5);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(123, 223, 242, 0.1);
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.chart-card.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-card.wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.live-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ pollers:
|
||||
interval: "30m"
|
||||
model: "ecmwf"
|
||||
|
||||
web:
|
||||
enabled: true
|
||||
listen: ":8080"
|
||||
|
||||
wunderground:
|
||||
enabled: true
|
||||
station_id: "ISSYDWXYZ123"
|
||||
|
||||
@@ -19,6 +19,8 @@ services:
|
||||
depends_on:
|
||||
- timescaledb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ type Config struct {
|
||||
} `yaml:"open_meteo"`
|
||||
} `yaml:"pollers"`
|
||||
|
||||
Web struct {
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Listen string `yaml:"listen"`
|
||||
} `yaml:"web"`
|
||||
|
||||
Wunderground struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
StationID string `yaml:"station_id"`
|
||||
@@ -77,6 +82,13 @@ func Load(path string) (*Config, error) {
|
||||
if c.Wunderground.Interval == 0 {
|
||||
c.Wunderground.Interval = 60 * time.Second
|
||||
}
|
||||
if c.Web.Listen == "" {
|
||||
c.Web.Listen = ":8080"
|
||||
}
|
||||
if c.Web.Enabled == nil {
|
||||
enabled := true
|
||||
c.Web.Enabled = &enabled
|
||||
}
|
||||
// If enabled, require creds
|
||||
if c.Wunderground.Enabled && (c.Wunderground.StationID == "" || c.Wunderground.StationKey == "") {
|
||||
return nil, errors.New("wunderground enabled but station_id/station_key not set")
|
||||
|
||||
220
internal/db/series.go
Normal file
220
internal/db/series.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ObservationPoint struct {
|
||||
TS time.Time `json:"ts"`
|
||||
TempC *float64 `json:"temp_c,omitempty"`
|
||||
RH *float64 `json:"rh,omitempty"`
|
||||
WindMS *float64 `json:"wind_m_s,omitempty"`
|
||||
WindGustMS *float64 `json:"wind_gust_m_s,omitempty"`
|
||||
WindDirDeg *float64 `json:"wind_dir_deg,omitempty"`
|
||||
UVI *float64 `json:"uvi,omitempty"`
|
||||
LightLux *float64 `json:"light_lux,omitempty"`
|
||||
}
|
||||
|
||||
type ForecastPoint struct {
|
||||
TS time.Time `json:"ts"`
|
||||
TempC *float64 `json:"temp_c,omitempty"`
|
||||
RH *float64 `json:"rh,omitempty"`
|
||||
PressureMSLH *float64 `json:"pressure_msl_hpa,omitempty"`
|
||||
WindMS *float64 `json:"wind_m_s,omitempty"`
|
||||
WindGustMS *float64 `json:"wind_gust_m_s,omitempty"`
|
||||
WindDirDeg *float64 `json:"wind_dir_deg,omitempty"`
|
||||
PrecipMM *float64 `json:"precip_mm,omitempty"`
|
||||
CloudCover *float64 `json:"cloud_cover,omitempty"`
|
||||
}
|
||||
|
||||
type ForecastSeries struct {
|
||||
RetrievedAt time.Time `json:"retrieved_at"`
|
||||
Points []ForecastPoint `json:"points"`
|
||||
}
|
||||
|
||||
func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSeconds int64) ([]ObservationPoint, error) {
|
||||
if rangeSeconds <= 0 {
|
||||
return nil, errors.New("range must be > 0")
|
||||
}
|
||||
|
||||
table := "cagg_ws90_5m"
|
||||
switch bucket {
|
||||
case "1m":
|
||||
table = "cagg_ws90_1m"
|
||||
case "5m":
|
||||
table = "cagg_ws90_5m"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported bucket: %s", bucket)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
bucket,
|
||||
temp_c_avg,
|
||||
rh_avg,
|
||||
wind_avg_ms_avg,
|
||||
wind_gust_ms_max,
|
||||
wind_dir_deg_avg,
|
||||
uvi_max,
|
||||
light_lux_max
|
||||
FROM %s
|
||||
WHERE site = $1
|
||||
AND bucket >= now() - make_interval(secs => $2)
|
||||
ORDER BY bucket ASC
|
||||
`, table)
|
||||
|
||||
rows, err := d.Pool.Query(ctx, query, site, rangeSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
points := make([]ObservationPoint, 0, 512)
|
||||
for rows.Next() {
|
||||
var (
|
||||
ts time.Time
|
||||
temp, rh, wind, gust sql.NullFloat64
|
||||
dir, uvi, light sql.NullFloat64
|
||||
)
|
||||
if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points = append(points, ObservationPoint{
|
||||
TS: ts,
|
||||
TempC: nullFloatPtr(temp),
|
||||
RH: nullFloatPtr(rh),
|
||||
WindMS: nullFloatPtr(wind),
|
||||
WindGustMS: nullFloatPtr(gust),
|
||||
WindDirDeg: nullFloatPtr(dir),
|
||||
UVI: nullFloatPtr(uvi),
|
||||
LightLux: nullFloatPtr(light),
|
||||
})
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPoint, error) {
|
||||
query := `
|
||||
SELECT
|
||||
ts,
|
||||
temperature_c,
|
||||
humidity,
|
||||
wind_avg_m_s,
|
||||
wind_max_m_s,
|
||||
wind_dir_deg,
|
||||
uvi,
|
||||
light_lux
|
||||
FROM observations_ws90
|
||||
WHERE site = $1
|
||||
ORDER BY ts DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var (
|
||||
ts time.Time
|
||||
temp, rh, wind, gust sql.NullFloat64
|
||||
dir, uvi, light sql.NullFloat64
|
||||
)
|
||||
err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ObservationPoint{
|
||||
TS: ts,
|
||||
TempC: nullFloatPtr(temp),
|
||||
RH: nullFloatPtr(rh),
|
||||
WindMS: nullFloatPtr(wind),
|
||||
WindGustMS: nullFloatPtr(gust),
|
||||
WindDirDeg: nullFloatPtr(dir),
|
||||
UVI: nullFloatPtr(uvi),
|
||||
LightLux: nullFloatPtr(light),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DB) ForecastSeriesLatest(ctx context.Context, site, model string) (ForecastSeries, error) {
|
||||
var retrieved sql.NullTime
|
||||
err := d.Pool.QueryRow(ctx, `
|
||||
SELECT max(retrieved_at)
|
||||
FROM forecast_openmeteo_hourly
|
||||
WHERE site = $1 AND model = $2
|
||||
`, site, model).Scan(&retrieved)
|
||||
if err != nil {
|
||||
return ForecastSeries{}, err
|
||||
}
|
||||
if !retrieved.Valid {
|
||||
return ForecastSeries{}, nil
|
||||
}
|
||||
|
||||
rows, err := d.Pool.Query(ctx, `
|
||||
SELECT
|
||||
ts,
|
||||
temp_c,
|
||||
rh,
|
||||
pressure_msl_hpa,
|
||||
wind_m_s,
|
||||
wind_gust_m_s,
|
||||
wind_dir_deg,
|
||||
precip_mm,
|
||||
cloud_cover
|
||||
FROM forecast_openmeteo_hourly
|
||||
WHERE site = $1 AND model = $2 AND retrieved_at = $3
|
||||
ORDER BY ts ASC
|
||||
`, site, model, retrieved.Time)
|
||||
if err != nil {
|
||||
return ForecastSeries{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
points := make([]ForecastPoint, 0, 256)
|
||||
for rows.Next() {
|
||||
var (
|
||||
ts time.Time
|
||||
temp, rh, msl, wind, gust sql.NullFloat64
|
||||
dir, precip, cloud sql.NullFloat64
|
||||
)
|
||||
if err := rows.Scan(&ts, &temp, &rh, &msl, &wind, &gust, &dir, &precip, &cloud); err != nil {
|
||||
return ForecastSeries{}, err
|
||||
}
|
||||
points = append(points, ForecastPoint{
|
||||
TS: ts,
|
||||
TempC: nullFloatPtr(temp),
|
||||
RH: nullFloatPtr(rh),
|
||||
PressureMSLH: nullFloatPtr(msl),
|
||||
WindMS: nullFloatPtr(wind),
|
||||
WindGustMS: nullFloatPtr(gust),
|
||||
WindDirDeg: nullFloatPtr(dir),
|
||||
PrecipMM: nullFloatPtr(precip),
|
||||
CloudCover: nullFloatPtr(cloud),
|
||||
})
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return ForecastSeries{}, rows.Err()
|
||||
}
|
||||
|
||||
return ForecastSeries{
|
||||
RetrievedAt: retrieved.Time,
|
||||
Points: points,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func nullFloatPtr(v sql.NullFloat64) *float64 {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
val := v.Float64
|
||||
return &val
|
||||
}
|
||||
@@ -17,13 +17,7 @@ type OpenMeteo struct {
|
||||
|
||||
func (o *OpenMeteo) Name() string { return "open_meteo" }
|
||||
|
||||
func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*ForecastResult, error) {
|
||||
if o.Client == nil {
|
||||
o.Client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
// Hourly fields supported by the ECMWF endpoint.
|
||||
hourly := []string{
|
||||
var openMeteoHourlyFields = []string{
|
||||
"temperature_2m",
|
||||
"pressure_msl",
|
||||
"wind_speed_10m",
|
||||
@@ -34,17 +28,37 @@ func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*Fo
|
||||
"relative_humidity_1000hPa",
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://api.open-meteo.com/v1/ecmwf")
|
||||
func OpenMeteoRequestURL(site Site, model string) (string, error) {
|
||||
u, err := url.Parse("https://api.open-meteo.com/v1/ecmwf")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("latitude", fmt.Sprintf("%.6f", site.Latitude))
|
||||
q.Set("longitude", fmt.Sprintf("%.6f", site.Longitude))
|
||||
q.Set("hourly", join(hourly))
|
||||
q.Set("hourly", join(openMeteoHourlyFields))
|
||||
q.Set("wind_speed_unit", "ms")
|
||||
q.Set("temperature_unit", "celsius")
|
||||
q.Set("precipitation_unit", "mm")
|
||||
u.RawQuery = q.Encode()
|
||||
_ = model // endpoint is fixed to ECMWF; model is metadata only.
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
safeURL := *u
|
||||
func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*ForecastResult, error) {
|
||||
if o.Client == nil {
|
||||
o.Client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
reqURL, err := OpenMeteoRequestURL(site, model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
safeURL, err := url.Parse(reqURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safeQuery := safeURL.Query()
|
||||
if safeQuery.Has("apikey") {
|
||||
safeQuery.Set("apikey", "redacted")
|
||||
@@ -56,7 +70,7 @@ func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*Fo
|
||||
defer cancel()
|
||||
go func() { <-ctxDone; cancel() }()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
resp, err := o.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user