add web ui

This commit is contained in:
2026-01-27 16:51:58 +11:00
parent 7526a7af93
commit 57a3a29914
12 changed files with 1158 additions and 23 deletions

View File

@@ -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
View 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
View 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
View 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
View 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;
}
}