HexaWetter v1.2.0: Security, Admin, Radar, UI
All checks were successful
CI / api-check (push) Successful in 20s
CI / frontend-check (push) Successful in 6s

This commit is contained in:
TheOnlyMace
2026-06-18 23:59:08 +02:00
parent 3ebdc96381
commit 5a08d4c4ac
72 changed files with 695 additions and 409 deletions

View File

@@ -10,12 +10,11 @@ let layerControl = null;
let osmLayer = null;
let radarLayer = null;
let warningLayer = null;
let localRadarOverlay = null;
let radarTimes = [];
let radarFrameIndex = 0;
let radarPlayTimer = null;
let radarPlaying = false;
let renderedFrames = [];
let hourlyChart = null;
let settings = loadSettings();
let searchTimer = null;
@@ -44,14 +43,6 @@ function saveFavorites(items) {
localStorage.setItem(FAV_KEY, JSON.stringify(items));
}
function formatBytes(bytes) {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let value = bytes, unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function weatherText(code) {
const map = {
0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt",
@@ -74,6 +65,139 @@ function convertWind(kmh) {
return `${Math.round(kmh)} km/h`;
}
function tempValue(celsius) {
if (settings.unitTemp === "fahrenheit") return celsius * 9 / 5 + 32;
return celsius;
}
function tempUnitLabel() {
return settings.unitTemp === "fahrenheit" ? "°F" : "°C";
}
function windUnitLabel() {
if (settings.unitWind === "ms") return "m/s";
if (settings.unitWind === "kn") return "kn";
return "km/h";
}
function windValue(kmh) {
if (settings.unitWind === "ms") return kmh / 3.6;
if (settings.unitWind === "kn") return kmh * 0.539957;
return kmh;
}
function chartThemeColors() {
const isLight = document.documentElement.dataset.theme === "light";
return {
text: isLight ? "#5f5380" : "#cfc9dd",
grid: isLight ? "rgba(20, 10, 60, 0.08)" : "rgba(255, 255, 255, 0.08)",
};
}
function destroyHourlyChart() {
if (hourlyChart) {
hourlyChart.destroy();
hourlyChart = null;
}
}
function renderHourlyChart(hourly) {
if (typeof Chart === "undefined" || !hourly?.time?.length) return;
const canvas = el("hourlyChart");
if (!canvas) return;
destroyHourlyChart();
const { text: textColor, grid: gridColor } = chartThemeColors();
const tempUnit = tempUnitLabel();
const windUnit = windUnitLabel();
const labels = hourly.time.slice(0, 48).map((time) =>
new Date(time).toLocaleString("de-DE", { weekday: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" })
);
hourlyChart = new Chart(canvas, {
type: "line",
data: {
labels,
datasets: [
{
type: "bar",
label: "Regenwahrscheinlichkeit",
data: hourly.precipitation_probability.slice(0, 48).map((v) => v ?? 0),
yAxisID: "yRain",
backgroundColor: "rgba(57, 120, 255, 0.42)",
borderColor: "rgba(57, 120, 255, 0.7)",
borderWidth: 1,
borderRadius: 4,
order: 3,
},
{
label: `Temperatur (${tempUnit})`,
data: hourly.temperature_2m.slice(0, 48).map(tempValue),
yAxisID: "yMain",
borderColor: "#ff51f9",
backgroundColor: "rgba(255, 81, 249, 0.14)",
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2.5,
order: 1,
},
{
label: `Wind (${windUnit})`,
data: hourly.wind_speed_10m.slice(0, 48).map((v) => windValue(v ?? 0)),
yAxisID: "yMain",
borderColor: "#a348ff",
borderDash: [5, 4],
tension: 0.35,
pointRadius: 0,
borderWidth: 1.5,
fill: false,
order: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: {
labels: { color: textColor, usePointStyle: true, boxWidth: 10 },
},
tooltip: {
callbacks: {
label(ctx) {
const label = ctx.dataset.label || "";
if (label.startsWith("Regen")) return `${label}: ${ctx.parsed.y}%`;
if (label.startsWith("Temperatur")) return `${label}: ${Math.round(ctx.parsed.y)} ${tempUnit}`;
return `${label}: ${ctx.parsed.y.toFixed(1)} ${windUnit}`;
},
},
},
},
scales: {
x: {
ticks: { color: textColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 14 },
grid: { color: gridColor },
},
yMain: {
position: "left",
ticks: { color: textColor },
grid: { color: gridColor },
},
yRain: {
position: "right",
min: 0,
max: 100,
grid: { drawOnChartArea: false },
ticks: { color: textColor, callback: (v) => `${v}%` },
},
},
},
});
}
async function api(path, options = {}) {
const res = await fetch(path, options);
if (!res.ok) {
@@ -126,15 +250,6 @@ async function loadHealth() {
}
}
async function loadSourceStatus() {
try {
const data = await api("/api/health/sources");
renderSourceStatus(data.sources || []);
} catch {
el("sourceStatusSummary").textContent = "Quellenstatus nicht verfügbar";
}
}
function applyDashboard(dash) {
const loc = getLocation();
if (dash.forecast?.data) {
@@ -207,18 +322,6 @@ function applyWarningsData(data) {
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
}
function renderSourceStatus(sources) {
const bad = sources.filter((s) => s.status === "error").length;
el("sourceStatusSummary").textContent = bad ? `${bad} Quelle(n) mit Problemen` : "Alle Quellen erreichbar";
el("sourceStatusGrid").innerHTML = sources.map((s) => `
<div class="source-status ${s.status}">
<strong>${s.name}</strong>
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
${s.error ? `<small>${s.error}</small>` : ""}
</div>
`).join("");
}
async function loadForecast() {
const loc = getLocation();
const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`);
@@ -270,6 +373,7 @@ function renderHourly(hourly) {
<td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td>
</tr>
`).join("");
renderHourlyChart(hourly);
}
function renderHourStrip(hourly) {
@@ -306,33 +410,7 @@ async function loadWarnings() {
const localOnly = el("warningsLocalOnly")?.checked ?? true;
try {
const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`);
const warnings = data.warnings || [];
el("warnCount").textContent = String(data.count_local ?? warnings.length);
el("warnMeta").textContent = localOnly
? `${warnings.length} Warnung(en) für deinen Standort`
: `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`;
const warncellEl = el("warncellInfo");
if (localOnly && data.warncells?.length) {
warncellEl.classList.remove("hidden");
warncellEl.innerHTML = `Zugeordnete Warngebiete: ${data.warncells.map((a) => `<strong>${a.name}</strong> (${a.warncell_id})`).join(" · ")}`;
} else {
warncellEl.classList.add("hidden");
warncellEl.innerHTML = "";
}
el("warningsList").innerHTML = warnings.length ? warnings.map((w) => `
<article class="warning-card ${warnLevelClass(w.level)}">
<div class="warning-head">
<strong>${w.headline || w.event}</strong>
<span class="warn-badge">Level ${w.level} · ${w.level_label}</span>
</div>
<p class="muted">${w.region_name || ""} · ${w.state || ""}</p>
<p>${(w.description || "").replace(/\n/g, "<br>")}</p>
${w.instruction ? `<p class="instruction">${w.instruction}</p>` : ""}
<small class="muted">Gültig: ${w.start ? formatDateTime(w.start) : ""} ${w.end ? formatDateTime(w.end) : ""}</small>
</article>
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
applyWarningsData(data);
} catch (err) {
el("warnCount").textContent = "";
el("warnMeta").textContent = err.message;
@@ -341,56 +419,6 @@ async function loadWarnings() {
}
}
async function loadRadarFiles() {
const latest = await api("/api/radar/latest?limit=12");
el("radarCount").textContent = `${latest.count} online`;
el("radarMeta").textContent = `Produkt ${latest.product.toUpperCase()} · DWD OpenData`;
const local = await api("/api/radar/files");
el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => `
<tr><td>${file.name}</td><td>${formatBytes(file.size_bytes)}</td>
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
<td><a href="${file.download_url}">Download</a></td></tr>
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
try {
const rendered = await api("/api/radar/rendered?limit=12");
renderedFrames = rendered.frames || [];
el("renderedCount").textContent = `${rendered.count} Frame(s)`;
el("renderedFrames").innerHTML = renderedFrames.length ? renderedFrames.map((f) => `
<figure class="rendered-frame">
<img src="${f.image_url}" alt="Radar ${f.filename}" loading="lazy" />
<figcaption>${f.filename}</figcaption>
</figure>
`).join("") : `<p class="muted">Noch keine gerenderten Frames. Renderer-Worker abwarten.</p>`;
} catch {
el("renderedCount").textContent = "";
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
}
}
async function syncRadar() {
const button = el("syncRadarBtn");
button.disabled = true;
button.textContent = "Sync läuft…";
try {
await api("/api/radar/sync?limit=6", { method: "POST" });
await loadRadarFiles();
} finally {
button.disabled = false;
button.textContent = "Radar jetzt syncen";
}
}
async function loadSources() {
try {
const data = await api("/api/sources");
el("sourceList").innerHTML = Object.entries(data).map(([key, value]) => `
<div class="source-item"><span>${key}</span><code>${String(value)}</code></div>
`).join("");
} catch (err) {
el("sourceList").innerHTML = `<p class="muted">${err.message}</p>`;
}
}
function setRadarTime(index) {
if (!radarTimes.length) return;
radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1));
@@ -399,24 +427,11 @@ function setRadarTime(index) {
el("radarTimeLabel").textContent = formatDateTime(time);
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
if (el("useLocalRadar").checked && renderedFrames.length) {
showLocalRadarFrame(radarFrameIndex);
} else if (radarLayer) {
if (radarLayer) {
radarLayer.setParams({ TIME: time, _cache: Date.now() });
}
}
function showLocalRadarFrame(index) {
if (!map) return;
const frame = renderedFrames[Math.min(index, renderedFrames.length - 1)];
if (!frame?.bounds) return;
const b = frame.bounds;
const bounds = [[b.south, b.west], [b.north, b.east]];
if (localRadarOverlay) map.removeLayer(localRadarOverlay);
localRadarOverlay = L.imageOverlay(frame.image_url + `?t=${Date.now()}`, bounds, { opacity: 0.75 });
localRadarOverlay.addTo(map);
}
async function loadRadarTimes() {
const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar";
const minutes = settings.radarMinutes || 120;
@@ -600,11 +615,33 @@ function activateView(name) {
document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.dataset.view === name));
if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150));
if (name === "warnings") loadWarnings();
if (name === "hourly" && hourlyChart) setTimeout(() => hourlyChart.resize(), 80);
}
async function loadDashboard() {
const loc = getLocation();
const localOnly = el("warningsLocalOnly")?.checked ?? true;
const dash = await api(`/api/dashboard?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`);
applyDashboard(dash);
if (dash.bundle_cached) {
el("forecastSource").textContent += " · Bundle-Cache";
}
return dash;
}
async function loadAll() {
await loadHealth();
await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]);
loadHealth();
const loc = getLocation();
try {
await loadDashboard();
} catch {
await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]);
const radar = await api("/api/radar/latest?limit=12").catch(() => null);
if (radar) {
el("radarCount").textContent = `${radar.count} online`;
el("radarMeta").textContent = `Produkt ${radar.product.toUpperCase()} · DWD OpenData`;
}
}
updateMapLocation();
if (map) loadRadarTimes();
}
@@ -614,20 +651,17 @@ async function init() {
await loadDefaultLocation();
renderFavorites();
await loadAll();
setInterval(loadHealth, 30000);
setInterval(loadHealth, 60000);
}
document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view)));
el("loadBtn").addEventListener("click", loadAll);
el("syncRadarBtn").addEventListener("click", syncRadar);
el("centerMapBtn").addEventListener("click", updateMapLocation);
el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers);
el("favBtn").addEventListener("click", addFavorite);
el("geoBtn").addEventListener("click", useGeolocation);
el("warningsLocalOnly")?.addEventListener("change", loadWarnings);
el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap);
el("useLocalRadar")?.addEventListener("change", () => setRadarTime(radarFrameIndex));
el("radarPlayBtn").addEventListener("click", toggleRadarPlay);
el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1));
el("radarNextBtn").addEventListener("click", () => setRadarTime(radarFrameIndex + 1));
@@ -647,6 +681,7 @@ el("themeToggle").addEventListener("click", () => {
settings.theme = settings.theme === "dark" ? "light" : "dark";
saveSettings();
applyTheme();
if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly);
});
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => {
@@ -656,7 +691,12 @@ el("themeToggle").addEventListener("click", () => {
if (id === "unitWind") settings.unitWind = e.target.value;
if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value);
saveSettings();
if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); loadForecast(); }
if (latestForecast) {
renderDaily(latestForecast.data.daily);
renderHourly(latestForecast.data.hourly);
renderHourStrip(latestForecast.data.hourly);
loadForecast();
}
if (id === "radarMinutes" && map) loadRadarTimes();
});
});