initial commit

This commit is contained in:
TheOnlyMace
2026-06-18 23:45:08 +02:00
commit 3ebdc96381
104 changed files with 4778 additions and 0 deletions

7
.cursor/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"plugins": {
"redis-development": {
"enabled": true
}
}
}

54
.env Normal file
View File

@@ -0,0 +1,54 @@
# HexaWetter basic settings
WEB_PORT=8080
API_PORT=8081
DOMAIN=wetter.example.com
TZ=Europe/Berlin
# Database
POSTGRES_PASSWORD=hexawetter
DATABASE_URL=postgresql://hexawetter:hexawetter@postgres:5432/hexawetter
REDIS_URL=redis://redis:6379/0
# API sources (replace with selfhosted instances when available)
OPEN_METEO_BASE_URL=https://api.open-meteo.com/v1
BRIGHTSKY_BASE_URL=https://api.brightsky.dev
DWD_RADAR_BASE_URL=https://opendata.dwd.de/weather/radar/composite
DWD_WMS_URL=https://maps.dwd.de/geoserver/dwd/wms
DWD_WARNINGS_URL=https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
NOMINATIM_USER_AGENT=HexaWetter/1.0 (selfhosted weather)
# Default location: Hauzenberg, DE
DEFAULT_LAT=48.6546
DEFAULT_LON=13.6252
DEFAULT_PLACE=Hauzenberg
# Radar settings
RADAR_PRODUCT=rv
RADAR_SYNC_LIMIT=12
RADAR_SYNC_INTERVAL_SECONDS=300
RADAR_RENDER_INTERVAL_SECONDS=300
RADAR_RETENTION_HOURS=48
# Cache TTL (seconds)
CACHE_TTL_FORECAST=600
CACHE_TTL_OBSERVATIONS=300
CACHE_TTL_WARNINGS=120
CACHE_TTL_GEOCODE=86400
CACHE_TTL_WMS_TIMES=120
# Admin (generate with: python scripts/hash_admin_password.py 'your-secure-password')
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=$$2b$$12$$BBgUdrmwTDiYGyzSRHNbh.2F70pkzBB/B3fIRN9kGlHGy1GUP.YzG
ADMIN_JWT_SECRET=mWXQRD1HANCZ-wPh5w4ZXokSwLmfgL1U-TbGveEELp9CGuwL-eyhooHLU4NzQYNV
ADMIN_JWT_EXPIRE_HOURS=8
ADMIN_LOGIN_MAX_ATTEMPTS=5
ADMIN_LOGIN_WINDOW_SECONDS=300
# Optional selfhosted profile
OPEN_METEO_PORT=8082
BRIGHTSKY_PORT=8083
BRIGHTSKY_DB_PASSWORD=brightsky
# Optional Traefik
TRAEFIK_CERTRESOLVER=letsencrypt

55
.env.example Normal file
View File

@@ -0,0 +1,55 @@
# HexaWetter basic settings
WEB_PORT=8080
API_PORT=8081
DOMAIN=wetter.example.com
TZ=Europe/Berlin
# Database
POSTGRES_PASSWORD=hexawetter
DATABASE_URL=postgresql://hexawetter:hexawetter@postgres:5432/hexawetter
REDIS_URL=redis://redis:6379/0
# API sources (replace with selfhosted instances when available)
OPEN_METEO_BASE_URL=https://api.open-meteo.com/v1
BRIGHTSKY_BASE_URL=https://api.brightsky.dev
DWD_RADAR_BASE_URL=https://opendata.dwd.de/weather/radar/composite
DWD_WMS_URL=https://maps.dwd.de/geoserver/dwd/wms
DWD_WARNINGS_URL=https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
NOMINATIM_USER_AGENT=HexaWetter/1.0 (selfhosted weather)
# Default location: Hauzenberg, DE
DEFAULT_LAT=48.6546
DEFAULT_LON=13.6252
DEFAULT_PLACE=Hauzenberg
# Radar settings
RADAR_PRODUCT=rv
RADAR_SYNC_LIMIT=12
RADAR_SYNC_INTERVAL_SECONDS=300
RADAR_RENDER_INTERVAL_SECONDS=300
RADAR_RETENTION_HOURS=48
# Cache TTL (seconds)
CACHE_TTL_FORECAST=600
CACHE_TTL_OBSERVATIONS=300
CACHE_TTL_WARNINGS=120
CACHE_TTL_GEOCODE=86400
CACHE_TTL_WMS_TIMES=120
# Admin (generate with: python scripts/hash_admin_password.py 'your-secure-password')
# bcrypt-Hashes enthalten $ — im .env fuer docker compose jedes $ als $$ schreiben!
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=
ADMIN_JWT_SECRET=
ADMIN_JWT_EXPIRE_HOURS=8
ADMIN_LOGIN_MAX_ATTEMPTS=5
ADMIN_LOGIN_WINDOW_SECONDS=300
# Optional selfhosted profile
OPEN_METEO_PORT=8082
BRIGHTSKY_PORT=8083
BRIGHTSKY_DB_PASSWORD=brightsky
# Optional Traefik
TRAEFIK_CERTRESOLVER=letsencrypt

27
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
jobs:
api-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install API dependencies
run: pip install -r api/requirements.txt
- name: Import API app
run: python -c "from app.main import app; print(app.title, app.version)"
working-directory: api
frontend-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify frontend files
run: test -f frontend/index.html && test -f frontend/app.js && test -f frontend/style.css

95
README.md Normal file
View File

@@ -0,0 +1,95 @@
# HexaWetter Selfhosted Wetterportal
HexaWetter ist ein vollständiges Self-Hosting-Wetterportal mit Forecast, DWD-Beobachtungen, interaktiver Radar-Karte (WMS + Zeitleiste), Warnungen, Geocoding und lokalem Radar-Rendering.
## Features
- **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast
- **Radar-Karte**: Leaflet + OpenStreetMap + DWD WMS (Niederschlagsradar, Warnungen)
- **Radar-Animation**: Zeitleiste (60120 Min., 5-Min-Schritte), Play/Pause, Vor/Zurück
- **Warnungen**: DWD-Warnungen mit Standortfilter (Landkreis-Matching)
- **Ortssuche**: PLZ/Ort via Nominatim-Proxy, GPS, Favoriten
- **Lokales Radar**: Worker lädt Rohdaten, Renderer erzeugt PNG-Overlays
- **Infrastruktur**: PostgreSQL + TimescaleDB + PostGIS, Redis-Cache, Healthchecks
## Admin-Bereich
```bash
pip install bcrypt
python scripts/hash_admin_password.py 'dein-sicheres-passwort'
```
Die ausgegebenen Werte in `.env` eintragen, dann Stack neu starten.
Admin-UI: `http://localhost:8080/admin.html`
Sicherheit:
- bcrypt-Passwort-Hash (niemals Klartext in `.env` committen)
- JWT mit `ADMIN_JWT_SECRET` (min. 32 Zeichen)
- Rate-Limiting auf Login (5 Versuche / 5 Min.)
- Token nur in `sessionStorage` (nicht localStorage)
- Security-Headers auf allen API-Antworten
```bash
cp .env.example .env
docker compose up -d --build
```
Web-UI: `http://localhost:8080` · API: `http://localhost:8081/api/health`
## Architektur
| Service | Rolle |
|---------|-------|
| `frontend` | Nginx + statisches UI |
| `api` | FastAPI-Backend |
| `postgres` | TimescaleDB + PostGIS (Cache, Radar-Metadaten) |
| `redis` | Kurzzeit-Cache |
| `radar-worker` | DWD OpenData Download |
| `radar-renderer` | HDF5 → PNG aus Rohdaten |
## API-Endpunkte (Auswahl)
| Endpoint | Beschreibung |
|----------|--------------|
| `GET /api/health` | Status inkl. Quellen-Healthchecks |
| `GET /api/forecast` | 7-Tage-Forecast |
| `GET /api/observations` | DWD-Beobachtung (Bright Sky) |
| `GET /api/warnings` | DWD-Warnungen (standortbezogen) |
| `GET /api/geocode/search` | Ortssuche |
| `GET /api/radar/wms/times` | WMS-Zeitschritte für Animation |
| `GET /api/radar/rendered` | Lokal gerenderte Radar-Frames |
## Selfhosted Forecast (optional)
```bash
docker compose -f docker-compose.yml -f docker-compose.selfhosted.yml --profile selfhosted up -d
```
Siehe `docs/NEXT_STEPS.md` für Hardware-Empfehlungen zu Open-Meteo und Bright Sky.
## Konfiguration
Wichtige `.env`-Variablen:
- `OPEN_METEO_BASE_URL`, `BRIGHTSKY_BASE_URL` Forecast/Beobachtungen
- `DWD_WMS_URL`, `DWD_RADAR_BASE_URL` Radar-Karte und Rohdaten
- `POSTGRES_PASSWORD`, `RADAR_RETENTION_HOURS`
- `CACHE_TTL_*`, `RATE_LIMIT_PER_MINUTE`
## Backup
- PostgreSQL: `docker exec hexawetter-postgres pg_dump -U hexawetter hexawetter > backup.sql`
- Radar-Rohdaten: `./data/raw/radar/`
- Gerenderte Frames: `./data/processed/radar/`
## Entwicklung
```bash
cd api && pip install -r requirements.txt && uvicorn app.main:app --reload --port 8081
```
## Lizenz / Attribution
Datenbasis: Deutscher Wetterdienst (DWD), Open Data. Kartenbasis: OpenStreetMap. Keine amtliche Wetterberatung.

18
api/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

0
api/app/__init__.py Normal file
View File

87
api/app/auth.py Normal file
View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import bcrypt
from jose import JWTError, jwt
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")
ADMIN_JWT_EXPIRE_HOURS = int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8"))
ADMIN_LOGIN_MAX_ATTEMPTS = int(os.getenv("ADMIN_LOGIN_MAX_ATTEMPTS", "5"))
ADMIN_LOGIN_WINDOW_SECONDS = int(os.getenv("ADMIN_LOGIN_WINDOW_SECONDS", "300"))
bearer_scheme = HTTPBearer(auto_error=False)
_login_attempts: dict[str, list[float]] = {}
def admin_configured() -> bool:
return bool(ADMIN_PASSWORD_HASH and ADMIN_JWT_SECRET and len(ADMIN_JWT_SECRET) >= 32)
def verify_password(plain_password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(plain_password.encode("utf-8"), password_hash.encode("utf-8"))
except (ValueError, TypeError):
return False
def hash_password(plain_password: str) -> str:
return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def _client_key(request: Request) -> str:
return request.client.host if request.client else "unknown"
def check_login_rate_limit(request: Request) -> None:
key = _client_key(request)
now = time.time()
attempts = [ts for ts in _login_attempts.get(key, []) if now - ts < ADMIN_LOGIN_WINDOW_SECONDS]
if len(attempts) >= ADMIN_LOGIN_MAX_ATTEMPTS:
raise HTTPException(status_code=429, detail="Too many login attempts. Try again later.")
_login_attempts[key] = attempts
def record_failed_login(request: Request) -> None:
key = _client_key(request)
_login_attempts.setdefault(key, []).append(time.time())
def create_access_token(username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=ADMIN_JWT_EXPIRE_HOURS)
payload = {"sub": username, "exp": expire, "scope": "admin"}
return jwt.encode(payload, ADMIN_JWT_SECRET, algorithm="HS256")
def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"])
async def require_admin(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> dict[str, Any]:
if not admin_configured():
raise HTTPException(status_code=503, detail="Admin access is not configured")
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
try:
payload = decode_access_token(credentials.credentials)
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
if payload.get("sub") != ADMIN_USERNAME or payload.get("scope") != "admin":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token scope")
return payload
def generate_secret(length: int = 48) -> str:
return secrets.token_urlsafe(length)

57
api/app/cache.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import json
import logging
from typing import Any
import redis.asyncio as redis
from app.config import REDIS_URL
logger = logging.getLogger(__name__)
_client: redis.Redis | None = None
async def init_redis() -> None:
global _client
try:
_client = redis.from_url(REDIS_URL, decode_responses=True)
await _client.ping()
logger.info("Redis connected")
except Exception as exc:
logger.warning("Redis unavailable, running without Redis cache: %s", exc)
_client = None
async def close_redis() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
def redis_available() -> bool:
return _client is not None
async def cache_get(key: str) -> Any | None:
if _client is None:
return None
raw = await _client.get(key)
if raw is None:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw
async def cache_set(key: str, value: Any, ttl_seconds: int) -> None:
if _client is None:
return
await _client.set(key, json.dumps(value), ex=ttl_seconds)
async def flush_cache() -> None:
if _client is not None:
await _client.flushdb()

48
api/app/config.py Normal file
View File

@@ -0,0 +1,48 @@
import os
from pathlib import Path
APP_NAME = "HexaWetter"
APP_VERSION = "1.1.0"
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
RENDERED_RADAR_DIR = DATA_DIR / "processed" / "radar"
OPEN_METEO_BASE_URL = os.getenv("OPEN_METEO_BASE_URL", "https://api.open-meteo.com/v1").rstrip("/")
BRIGHTSKY_BASE_URL = os.getenv("BRIGHTSKY_BASE_URL", "https://api.brightsky.dev").rstrip("/")
DWD_RADAR_BASE_URL = os.getenv("DWD_RADAR_BASE_URL", "https://opendata.dwd.de/weather/radar/composite").rstrip("/")
DWD_WMS_URL = os.getenv("DWD_WMS_URL", "https://maps.dwd.de/geoserver/dwd/wms").rstrip("/")
DWD_WARNINGS_URL = os.getenv(
"DWD_WARNINGS_URL",
"https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json",
).rstrip("/")
NOMINATIM_BASE_URL = os.getenv("NOMINATIM_BASE_URL", "https://nominatim.openstreetmap.org").rstrip("/")
NOMINATIM_USER_AGENT = os.getenv("NOMINATIM_USER_AGENT", "HexaWetter/1.0 (selfhosted weather)")
DEFAULT_LAT = float(os.getenv("DEFAULT_LAT", "48.6546"))
DEFAULT_LON = float(os.getenv("DEFAULT_LON", "13.6252"))
DEFAULT_PLACE = os.getenv("DEFAULT_PLACE", "Hauzenberg")
RADAR_PRODUCT = os.getenv("RADAR_PRODUCT", "rv")
RADAR_RETENTION_HOURS = int(os.getenv("RADAR_RETENTION_HOURS", "48"))
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://hexawetter:hexawetter@postgres:5432/hexawetter",
)
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
CACHE_TTL_FORECAST = int(os.getenv("CACHE_TTL_FORECAST", "600"))
CACHE_TTL_OBSERVATIONS = int(os.getenv("CACHE_TTL_OBSERVATIONS", "300"))
CACHE_TTL_WARNINGS = int(os.getenv("CACHE_TTL_WARNINGS", "120"))
CACHE_TTL_GEOCODE = int(os.getenv("CACHE_TTL_GEOCODE", "86400"))
CACHE_TTL_WMS_TIMES = int(os.getenv("CACHE_TTL_WMS_TIMES", "120"))
CACHE_TTL_HEALTH_SOURCES = int(os.getenv("CACHE_TTL_HEALTH_SOURCES", "90"))
CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60"))
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120"))
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")
ADMIN_JWT_EXPIRE_HOURS = int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8"))

213
api/app/database.py Normal file
View File

@@ -0,0 +1,213 @@
from __future__ import annotations
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
import asyncpg
from app.config import DATABASE_URL
logger = logging.getLogger(__name__)
_pool: asyncpg.Pool | None = None
async def init_db() -> None:
global _pool
try:
_pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=8, command_timeout=30)
async with _pool.acquire() as conn:
await conn.execute("SELECT 1")
logger.info("PostgreSQL connected")
except Exception as exc:
logger.warning("PostgreSQL unavailable, running without DB cache: %s", exc)
_pool = None
async def close_db() -> None:
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def pool_available() -> bool:
return _pool is not None
@asynccontextmanager
async def acquire():
if _pool is None:
yield None
return
async with _pool.acquire() as conn:
yield conn
async def get_cached_json(table: str, key_cols: dict[str, Any], max_age_seconds: int) -> Any | None:
async with acquire() as conn:
if conn is None:
return None
if table == "forecast_cache":
row = await conn.fetchrow(
"""
SELECT data, fetched_at FROM forecast_cache
WHERE lat = $1 AND lon = $2
AND fetched_at > NOW() - ($3 || ' seconds')::interval
""",
key_cols["lat"],
key_cols["lon"],
str(max_age_seconds),
)
elif table == "observations_cache":
row = await conn.fetchrow(
"""
SELECT data, fetched_at FROM observations_cache
WHERE lat = $1 AND lon = $2
AND fetched_at > NOW() - ($3 || ' seconds')::interval
""",
key_cols["lat"],
key_cols["lon"],
str(max_age_seconds),
)
elif table == "geocode_cache":
row = await conn.fetchrow(
"""
SELECT results, fetched_at FROM geocode_cache
WHERE query_hash = $1
AND fetched_at > NOW() - ($2 || ' seconds')::interval
""",
key_cols["query_hash"],
str(max_age_seconds),
)
else:
return None
if not row:
return None
data = row["data"] if "data" in row else row["results"]
return json.loads(data) if isinstance(data, str) else data
async def set_cached_json(table: str, key_cols: dict[str, Any], payload: Any) -> None:
async with acquire() as conn:
if conn is None:
return
payload_json = json.dumps(payload)
if table == "forecast_cache":
await conn.execute(
"""
INSERT INTO forecast_cache (lat, lon, data, fetched_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (lat, lon) DO UPDATE
SET data = EXCLUDED.data, fetched_at = NOW()
""",
key_cols["lat"],
key_cols["lon"],
payload_json,
)
elif table == "observations_cache":
await conn.execute(
"""
INSERT INTO observations_cache (lat, lon, data, fetched_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (lat, lon) DO UPDATE
SET data = EXCLUDED.data, fetched_at = NOW()
""",
key_cols["lat"],
key_cols["lon"],
payload_json,
)
elif table == "geocode_cache":
await conn.execute(
"""
INSERT INTO geocode_cache (query_hash, query_text, results, fetched_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (query_hash) DO UPDATE
SET results = EXCLUDED.results, fetched_at = NOW(), query_text = EXCLUDED.query_text
""",
key_cols["query_hash"],
key_cols.get("query_text", ""),
payload_json,
)
elif table == "warnings_cache":
await conn.execute(
"INSERT INTO warnings_cache (data, fetched_at) VALUES ($1::jsonb, NOW())",
payload_json,
)
async def upsert_radar_meta(
product: str,
filename: str,
file_size: int | None,
modified_at: datetime | None,
render_status: str = "pending",
render_path: str | None = None,
render_bounds: dict | None = None,
render_error: str | None = None,
) -> None:
async with acquire() as conn:
if conn is None:
return
await conn.execute(
"""
INSERT INTO radar_file_meta (
product, filename, file_size, modified_at, render_status,
render_path, render_bounds, render_error, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, NOW())
ON CONFLICT (filename) DO UPDATE SET
file_size = EXCLUDED.file_size,
modified_at = EXCLUDED.modified_at,
render_status = EXCLUDED.render_status,
render_path = EXCLUDED.render_path,
render_bounds = EXCLUDED.render_bounds,
render_error = EXCLUDED.render_error,
updated_at = NOW()
""",
product,
filename,
file_size,
modified_at,
render_status,
render_path,
json.dumps(render_bounds) if render_bounds else None,
render_error,
)
async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, Any]]:
async with acquire() as conn:
if conn is None:
return []
rows = await conn.fetch(
"""
SELECT filename, render_path, render_bounds, modified_at, updated_at
FROM radar_file_meta
WHERE product = $1 AND render_status = 'done' AND render_path IS NOT NULL
ORDER BY modified_at DESC NULLS LAST
LIMIT $2
""",
product,
limit,
)
result = []
for row in rows:
bounds = row["render_bounds"]
if isinstance(bounds, str):
bounds = json.loads(bounds)
result.append(
{
"filename": row["filename"],
"image_url": f"/api/radar/rendered/{product}/{row['filename']}.png",
"bounds": bounds,
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
"rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
)
return result
def utc_now() -> datetime:
return datetime.now(timezone.utc)

41
api/app/http_client.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import httpx
_client: httpx.AsyncClient | None = None
async def init_http_client() -> None:
global _client
_client = httpx.AsyncClient(
timeout=httpx.Timeout(20.0, connect=5.0),
follow_redirects=True,
limits=httpx.Limits(max_connections=40, max_keepalive_connections=20),
)
async def close_http_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
def get_client() -> httpx.AsyncClient:
if _client is None:
raise RuntimeError("HTTP client not initialized")
return _client
async def fetch_json(url: str, params: dict | None = None, timeout: float | None = None) -> dict:
client = get_client()
response = await client.get(url, params=params, timeout=timeout)
response.raise_for_status()
return response.json()
async def fetch_text(url: str, timeout: float | None = None) -> str:
client = get_client()
response = await client.get(url, timeout=timeout)
response.raise_for_status()
return response.text

404
api/app/main.py Normal file
View File

@@ -0,0 +1,404 @@
from __future__ import annotations
import logging
import os
import re
import time
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
from app.auth import admin_configured
from app.cache import close_redis, init_redis, redis_available
from app.config import (
APP_NAME,
APP_VERSION,
BRIGHTSKY_BASE_URL,
DEFAULT_LAT,
DEFAULT_LON,
DEFAULT_PLACE,
DWD_RADAR_BASE_URL,
DWD_WMS_URL,
OPEN_METEO_BASE_URL,
RADAR_PRODUCT,
RADAR_RETENTION_HOURS,
RATE_LIMIT_PER_MINUTE,
RENDERED_RADAR_DIR,
RAW_RADAR_DIR,
)
from app.database import (
close_db,
init_db,
list_rendered_radar,
pool_available,
upsert_radar_meta,
)
from app.http_client import close_http_client, get_client, init_http_client
from app.services.dashboard import build_dashboard
from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index
from app.services.geocoding import reverse_geocode, search_places
from app.services.health import check_sources
from app.routes.admin import router as admin_router
from app.services.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations
from app.services.wms import fetch_wms_time_steps
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger(__name__)
_rate_buckets: dict[str, list[float]] = defaultdict(list)
class Location(BaseModel):
lat: float = DEFAULT_LAT
lon: float = DEFAULT_LON
place: str = DEFAULT_PLACE
class FavoriteLocation(BaseModel):
place: str
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
@asynccontextmanager
async def lifespan(_: FastAPI):
await init_http_client()
await init_db()
await init_redis()
RENDERED_RADAR_DIR.mkdir(parents=True, exist_ok=True)
RAW_RADAR_DIR.mkdir(parents=True, exist_ok=True)
yield
await close_redis()
await close_db()
await close_http_client()
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
app.include_router(admin_router)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(self)"
return response
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if not request.url.path.startswith("/api/"):
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
now = time.time()
limit = 20 if request.url.path == "/api/admin/login" else RATE_LIMIT_PER_MINUTE
bucket = _rate_buckets[client_ip]
_rate_buckets[client_ip] = [ts for ts in bucket if now - ts < 60]
if len(_rate_buckets[client_ip]) >= limit:
return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"})
_rate_buckets[client_ip].append(now)
return await call_next(request)
def utc_now() -> datetime:
return datetime.now(timezone.utc)
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {
"status": "ok",
"service": APP_NAME,
"version": APP_VERSION,
"time_utc": utc_now().isoformat(),
"admin_enabled": admin_configured(),
"postgres": pool_available(),
"redis": redis_available(),
}
@app.get("/api/health/sources")
async def health_sources() -> dict[str, Any]:
sources = await check_sources()
degraded = [s for s in sources if s.get("status") not in ("ok", "disabled")]
return {
"status": "degraded" if degraded else "ok",
"time_utc": utc_now().isoformat(),
"sources": sources,
}
@app.get("/api/dashboard")
async def dashboard(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
local_only: bool = Query(True),
) -> dict[str, Any]:
try:
return await build_dashboard(lat, lon, local_only=local_only)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Dashboard unavailable: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc
def cleanup_old_radar_files() -> None:
cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS)
if not RAW_RADAR_DIR.exists():
return
for path in RAW_RADAR_DIR.rglob("*"):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
if modified < cutoff:
path.unlink(missing_ok=True)
@app.get("/api/location/default")
async def default_location() -> Location:
return Location()
@app.get("/api/geocode/search")
async def geocode_search(q: str = Query(..., min_length=2), limit: int = Query(8, ge=1, le=15)) -> dict[str, Any]:
try:
results = await search_places(q, limit=limit)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Geocoding unavailable: {exc}") from exc
return {"query": q, "count": len(results), "results": results}
@app.get("/api/geocode/reverse")
async def geocode_reverse(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
) -> dict[str, Any]:
try:
result = await reverse_geocode(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Reverse geocoding unavailable: {exc}") from exc
return {"lat": lat, "lon": lon, "result": result}
@app.get("/api/forecast")
async def forecast(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
) -> dict[str, Any]:
try:
return await get_forecast(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc
@app.get("/api/observations")
async def observations(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
) -> dict[str, Any]:
try:
return await get_observations(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Observation source unavailable: {exc}") from exc
@app.get("/api/warnings")
async def warnings(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
local_only: bool = Query(True),
) -> dict[str, Any]:
try:
return await fetch_warnings_for_location(lat, lon, local_only=local_only)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Warnings unavailable: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Warnings parse error: {exc}") from exc
@app.get("/api/radar/latest")
async def radar_latest(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(12, ge=1, le=100),
) -> dict[str, Any]:
try:
return await fetch_radar_index(product=product, limit=limit)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc
@app.post("/api/radar/sync")
async def radar_sync(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(6, ge=1, le=50),
) -> dict[str, Any]:
latest = await radar_latest(product=product, limit=limit)
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
downloaded = []
skipped = []
client = get_client()
for item in latest["files"]:
out = target_dir / item["name"]
if out.exists() and out.stat().st_size > 0:
skipped.append(item["name"])
continue
response = await client.get(item["url"], timeout=90.0)
response.raise_for_status()
out.write_bytes(response.content)
downloaded.append(item["name"])
modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc)
await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending")
cleanup_old_radar_files()
return {"product": product, "target_dir": str(target_dir), "downloaded": downloaded, "skipped": skipped}
@app.get("/api/radar/files")
async def radar_files(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
) -> dict[str, Any]:
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
files = []
for path in sorted(target_dir.iterdir()):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
files.append(
{
"name": path.name,
"size_bytes": path.stat().st_size,
"modified_utc": modified.isoformat(),
"download_url": f"/api/radar/files/{product}/{path.name}",
}
)
return {"product": product, "path": str(target_dir), "count": len(files), "files": files}
@app.get("/api/radar/files/{product}/{filename}")
async def radar_file_download(product: str, filename: str):
if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid product")
if not RADAR_FILE_RE.fullmatch(filename):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RAW_RADAR_DIR / "composite" / product / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream"
return FileResponse(path, filename=filename, media_type=media_type)
@app.get("/api/radar/rendered")
async def radar_rendered_list(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(24, ge=1, le=100),
) -> dict[str, Any]:
frames = await list_rendered_radar(product, limit=limit)
return {"product": product, "count": len(frames), "frames": frames}
@app.get("/api/radar/rendered/{product}/{filename}.png")
async def radar_rendered_image(product: str, filename: str):
if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RENDERED_RADAR_DIR / product / f"{filename}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Rendered image not found")
return FileResponse(path, media_type="image/png")
@app.get("/api/radar/wms")
async def radar_wms() -> dict[str, Any]:
return {
"service_url": DWD_WMS_URL,
"attribution": "Radar/Warnungen: Deutscher Wetterdienst (DWD)",
"default_layer": "dwd:Niederschlagsradar",
"layers": [
{
"id": "niederschlagsradar",
"title": "Niederschlagsradar",
"layer": "dwd:Niederschlagsradar",
"opacity": 0.75,
"enabled": True,
"animated": True,
},
{
"id": "warnungen_gemeinden",
"title": "DWD Warnungen (Gemeinden)",
"layer": "dwd:Warnungen_Gemeinden_vereinigt",
"opacity": 0.6,
"enabled": True,
"animated": False,
},
{
"id": "radolan_ry",
"title": "RADOLAN RY",
"layer": "dwd:RADOLAN-RY",
"opacity": 0.72,
"enabled": False,
"animated": False,
},
{
"id": "radar_wn",
"title": "Radar WN Reflektivität",
"layer": "dwd:Radar_wn-product_1x1km_ger",
"opacity": 0.68,
"enabled": False,
"animated": True,
},
{
"id": "warnungen_landkreise",
"title": "DWD Warnungen Landkreise",
"layer": "dwd:Warnungen_Landkreise",
"opacity": 0.55,
"enabled": False,
"animated": False,
},
],
}
@app.get("/api/radar/wms/times")
async def radar_wms_times(
layer: str = Query("dwd:Niederschlagsradar"),
minutes: int = Query(120, ge=15, le=180),
) -> dict[str, Any]:
try:
return await fetch_wms_time_steps(layer, minutes=minutes)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc
@app.get("/api/sources")
async def sources() -> dict[str, Any]:
return {
"note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.",
"open_meteo_base_url": OPEN_METEO_BASE_URL,
"brightsky_base_url": BRIGHTSKY_BASE_URL,
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
"dwd_wms_url": DWD_WMS_URL,
"dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.",
"osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.",
}

View File

154
api/app/routes/admin.py Normal file
View File

@@ -0,0 +1,154 @@
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.auth import (
ADMIN_USERNAME,
admin_configured,
check_login_rate_limit,
create_access_token,
record_failed_login,
require_admin,
verify_password,
)
from app.cache import cache_get, cache_set, redis_available
from app.config import (
APP_NAME,
APP_VERSION,
BRIGHTSKY_BASE_URL,
CACHE_TTL_FORECAST,
CACHE_TTL_GEOCODE,
CACHE_TTL_OBSERVATIONS,
CACHE_TTL_WARNINGS,
CACHE_TTL_WMS_TIMES,
DATA_DIR,
DWD_RADAR_BASE_URL,
DWD_WMS_URL,
OPEN_METEO_BASE_URL,
RADAR_PRODUCT,
RADAR_RETENTION_HOURS,
RATE_LIMIT_PER_MINUTE,
RAW_RADAR_DIR,
RENDERED_RADAR_DIR,
)
from app.database import pool_available
from app.services.health import check_sources
router = APIRouter(prefix="/api/admin", tags=["admin"])
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=8, max_length=128)
class SettingsUpdate(BaseModel):
rate_limit_per_minute: int | None = Field(default=None, ge=10, le=1000)
cache_ttl_forecast: int | None = Field(default=None, ge=60, le=86400)
cache_ttl_observations: int | None = Field(default=None, ge=60, le=86400)
cache_ttl_warnings: int | None = Field(default=None, ge=30, le=3600)
radar_retention_hours: int | None = Field(default=None, ge=6, le=168)
def _dir_stats(path: Path) -> dict[str, Any]:
if not path.exists():
return {"path": str(path), "exists": False, "files": 0, "bytes": 0}
files = [p for p in path.rglob("*") if p.is_file()]
total = sum(p.stat().st_size for p in files)
return {"path": str(path), "exists": True, "files": len(files), "bytes": total}
def _public_config() -> dict[str, Any]:
return {
"app_name": APP_NAME,
"version": APP_VERSION,
"open_meteo_base_url": OPEN_METEO_BASE_URL,
"brightsky_base_url": BRIGHTSKY_BASE_URL,
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
"dwd_wms_url": DWD_WMS_URL,
"radar_product": RADAR_PRODUCT,
"radar_retention_hours": RADAR_RETENTION_HOURS,
"rate_limit_per_minute": RATE_LIMIT_PER_MINUTE,
"cache_ttl": {
"forecast": CACHE_TTL_FORECAST,
"observations": CACHE_TTL_OBSERVATIONS,
"warnings": CACHE_TTL_WARNINGS,
"geocode": CACHE_TTL_GEOCODE,
"wms_times": CACHE_TTL_WMS_TIMES,
},
}
@router.get("/status")
async def admin_status_public() -> dict[str, Any]:
return {"admin_enabled": admin_configured(), "version": APP_VERSION}
@router.post("/login")
async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]:
if not admin_configured():
raise HTTPException(status_code=503, detail="Admin login is not configured on this instance")
check_login_rate_limit(request)
if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")):
record_failed_login(request)
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(payload.username)
return {
"access_token": token,
"token_type": "bearer",
"expires_in_hours": int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8")),
}
@router.get("/dashboard")
async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
sources = await check_sources()
return {
"time_utc": datetime.now(timezone.utc).isoformat(),
"services": sources,
"infrastructure": {
"postgres": "ok" if pool_available() else "disabled",
"redis": "ok" if redis_available() else "disabled",
},
"storage": {
"data_dir": _dir_stats(DATA_DIR),
"radar_raw": _dir_stats(RAW_RADAR_DIR),
"radar_rendered": _dir_stats(RENDERED_RADAR_DIR),
},
"config": _public_config(),
}
@router.post("/cache/clear")
async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
from app.cache import flush_cache
if not redis_available():
raise HTTPException(status_code=503, detail="Redis cache is not available")
await flush_cache()
return {"status": "ok", "message": "Redis cache cleared"}
@router.get("/settings")
async def admin_get_settings(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
runtime = await cache_get("admin:runtime_settings") or {}
return {"defaults": _public_config(), "runtime_overrides": runtime}
@router.put("/settings")
async def admin_update_settings(payload: SettingsUpdate, _: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
from app.cache import cache_set
current = await cache_get("admin:runtime_settings") or {}
updates = payload.model_dump(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No settings provided")
current.update(updates)
await cache_set("admin:runtime_settings", current, ttl_seconds=86400 * 30)
return {"status": "ok", "runtime_overrides": current, "note": "Overrides are stored in Redis. Service restart applies full .env changes."}

View File

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.cache import cache_get, cache_set
from app.config import CACHE_TTL_DASHBOARD, CACHE_TTL_OBSERVATIONS, CACHE_TTL_WARNINGS
from app.services.dwd_radar import fetch_radar_index
from app.services.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations
logger = logging.getLogger(__name__)
async def get_radar_summary() -> dict[str, Any]:
redis_key = "radar:summary"
cached = await cache_get(redis_key)
if cached:
return {**cached, "cached": True}
index = await fetch_radar_index(limit=12)
payload = {
"source": index["source"],
"product": index["product"],
"count": index["count"],
"cached": False,
}
await cache_set(redis_key, payload, 120)
return payload
async def build_dashboard(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]:
redis_key = f"dashboard:{round(lat, 4)}:{round(lon, 4)}:{int(local_only)}"
cached = await cache_get(redis_key)
if cached:
return {**cached, "bundle_cached": True}
results = await asyncio.gather(
get_forecast(lat, lon),
get_observations(lat, lon),
fetch_warnings_for_location(lat, lon, local_only=local_only),
get_radar_summary(),
return_exceptions=True,
)
payload: dict[str, Any] = {
"lat": lat,
"lon": lon,
"bundle_cached": False,
"errors": {},
}
names = ("forecast", "observations", "warnings", "radar")
for name, result in zip(names, results):
if isinstance(result, Exception):
logger.warning("dashboard %s failed: %s", name, result)
payload["errors"][name] = str(result)
payload[name] = None
else:
payload[name] = result
ttl = min(CACHE_TTL_DASHBOARD, CACHE_TTL_OBSERVATIONS, CACHE_TTL_WARNINGS)
if not payload["errors"]:
await cache_set(redis_key, payload, ttl)
return payload

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import re
from typing import Any
from urllib.parse import urljoin
from app.config import DWD_RADAR_BASE_URL, RADAR_PRODUCT
from app.http_client import fetch_text
RADAR_FILE_RE = re.compile(r"^[A-Za-z0-9_.-]+\.(?:tar|tar\.bz2|bz2|gz)$", re.IGNORECASE)
def parse_apache_index(html: str, suffixes: tuple[str, ...] | None = None) -> list[dict[str, Any]]:
files: list[dict[str, Any]] = []
pattern = re.compile(
r'href="(?P<href>[^"]+)"[^>]*>[^<]+</a>\s*'
r'(?P<date>\d{2}-[A-Za-z]{3}-\d{4})\s+(?P<time>\d{2}:\d{2}(?::\d{2})?)\s+(?P<size>[0-9\-]+)?',
re.IGNORECASE,
)
for match in pattern.finditer(html):
href = match.group("href")
if href.startswith("../") or href.endswith("/"):
continue
if suffixes and not href.lower().endswith(tuple(s.lower() for s in suffixes)):
continue
files.append(
{
"name": href,
"modified": f"{match.group('date')} {match.group('time')}",
"size_bytes": None if match.group("size") in (None, "-") else int(match.group("size")),
}
)
return files
async def fetch_radar_index(product: str = RADAR_PRODUCT, limit: int = 12) -> dict[str, Any]:
base_url = f"{DWD_RADAR_BASE_URL}/{product}/"
html = await fetch_text(base_url, timeout=12.0)
files = parse_apache_index(html, suffixes=(".tar", ".tar.bz2", ".bz2", ".gz"))
files = files[-limit:]
for item in files:
item["url"] = urljoin(base_url, item["name"])
return {"source": base_url, "product": product, "count": len(files), "files": files}

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
import hashlib
from typing import Any
from app.config import CACHE_TTL_GEOCODE, NOMINATIM_BASE_URL, NOMINATIM_USER_AGENT
from app.http_client import get_client
from app.database import get_cached_json, set_cached_json
def _query_hash(query: str) -> str:
return hashlib.sha256(query.strip().lower().encode("utf-8")).hexdigest()
async def _nominatim_get(path: str, params: dict[str, Any]) -> Any:
headers = {"User-Agent": NOMINATIM_USER_AGENT, "Accept-Language": "de"}
client = get_client()
response = await client.get(f"{NOMINATIM_BASE_URL}{path}", params=params, headers=headers, timeout=12.0)
response.raise_for_status()
return response.json()
async def search_places(query: str, limit: int = 8) -> list[dict[str, Any]]:
if len(query.strip()) < 2:
return []
qhash = _query_hash(f"search:{query}:{limit}")
cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE)
if cached is not None:
return cached
raw = await _nominatim_get(
"/search",
{
"q": query,
"format": "jsonv2",
"addressdetails": 1,
"limit": limit,
"countrycodes": "de,at,ch",
},
)
results = []
for item in raw:
address = item.get("address") or {}
label = item.get("display_name", "")
place = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("county")
or label.split(",")[0]
)
results.append(
{
"label": label,
"place": place,
"lat": float(item["lat"]),
"lon": float(item["lon"]),
"type": item.get("type"),
"postcode": address.get("postcode"),
"county": address.get("county"),
"state": address.get("state"),
}
)
await set_cached_json(
"geocode_cache",
{"query_hash": qhash, "query_text": query},
results,
)
return results
async def reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
qhash = _query_hash(f"reverse:{lat:.4f}:{lon:.4f}")
cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE)
if cached is not None:
return cached
raw = await _nominatim_get(
"/reverse",
{"lat": lat, "lon": lon, "format": "jsonv2", "addressdetails": 1, "zoom": 10},
)
address = raw.get("address") or {}
payload = {
"label": raw.get("display_name"),
"place": address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("county"),
"county": address.get("county"),
"state": address.get("state"),
"postcode": address.get("postcode"),
"admin_names": [
value
for value in [
address.get("city"),
address.get("town"),
address.get("village"),
address.get("municipality"),
address.get("county"),
address.get("state"),
]
if value
],
}
await set_cached_json(
"geocode_cache",
{"query_hash": qhash, "query_text": f"{lat},{lon}"},
payload,
)
return payload

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.cache import cache_get, cache_set, redis_available
from app.config import (
BRIGHTSKY_BASE_URL,
CACHE_TTL_HEALTH_SOURCES,
DATABASE_URL,
DWD_RADAR_BASE_URL,
DWD_WMS_URL,
DWD_WARNINGS_URL,
OPEN_METEO_BASE_URL,
)
from app.database import pool_available
from app.http_client import get_client
async def _probe(name: str, url: str, timeout: float = 5.0) -> dict[str, Any]:
started = time.perf_counter()
try:
client = get_client()
response = await client.get(url, timeout=timeout)
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
ok = response.status_code < 500
return {
"name": name,
"url": url,
"status": "ok" if ok else "degraded",
"http_status": response.status_code,
"latency_ms": elapsed_ms,
}
except Exception as exc:
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
return {
"name": name,
"url": url,
"status": "error",
"error": str(exc),
"latency_ms": elapsed_ms,
}
async def check_sources() -> list[dict[str, Any]]:
cache_key = "health:sources"
cached = await cache_get(cache_key)
if cached:
return cached
probes = await asyncio.gather(
_probe("open_meteo", f"{OPEN_METEO_BASE_URL}/forecast?latitude=52.5&longitude=13.4&current=temperature_2m"),
_probe("brightsky", f"{BRIGHTSKY_BASE_URL}/current_weather?lat=52.5&lon=13.4"),
_probe("dwd_radar", f"{DWD_RADAR_BASE_URL}/rv/", timeout=4.0),
_probe("dwd_wms", f"{DWD_WMS_URL}?service=WMS&request=GetCapabilities", timeout=6.0),
_probe("dwd_warnings", DWD_WARNINGS_URL, timeout=4.0),
)
results = list(probes)
results.append(
{
"name": "postgres",
"url": DATABASE_URL.split("@")[-1],
"status": "ok" if pool_available() else "disabled",
}
)
results.append(
{
"name": "redis",
"url": "redis",
"status": "ok" if redis_available() else "disabled",
}
)
await cache_set(cache_key, results, CACHE_TTL_HEALTH_SOURCES)
return results

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
import httpx
from app.cache import cache_get, cache_set
from app.config import CACHE_TTL_GEOCODE, DWD_WMS_URL
from app.http_client import get_client
logger = logging.getLogger(__name__)
WFS_URL = f"{DWD_WMS_URL.rsplit('/wms', 1)[0]}/ows"
# Landkreise reicht fuer warnapp_landkreise/json/warnings.json
WFS_LAYERS = ("dwd:Warngebiete_Kreise",)
async def _wfs_intersects(layer: str, lat: float, lon: float) -> list[dict[str, Any]]:
params = {
"service": "WFS",
"version": "2.0.0",
"request": "GetFeature",
"typeName": layer,
"outputFormat": "application/json",
"count": 5,
"CQL_FILTER": f"INTERSECTS(SHAPE,POINT({lat} {lon}))",
}
client = get_client()
response = await client.get(WFS_URL, params=params, timeout=8.0)
response.raise_for_status()
payload = response.json()
results = []
for feature in payload.get("features", []):
props = feature.get("properties") or {}
cell_id = props.get("WARNCELLID")
if cell_id is None:
continue
results.append(
{
"warncell_id": str(cell_id),
"name": props.get("NAME"),
"short_name": props.get("KURZNAME"),
"layer": layer.split(":")[-1],
"state": props.get("BL"),
}
)
return results
def _related_cell_ids(cell_id: str) -> set[str]:
ids = {cell_id}
if len(cell_id) >= 9:
core = cell_id[1:]
ids.add(f"1{core}")
ids.add(f"9{core}")
if cell_id.startswith("9"):
ids.add(f"1{cell_id[1:]}")
elif cell_id.startswith("1"):
ids.add(f"9{cell_id[1:]}")
return ids
async def resolve_warncells(lat: float, lon: float) -> dict[str, Any]:
cache_key = f"warncell:{lat:.4f}:{lon:.4f}"
cached = await cache_get(cache_key)
if cached:
return cached
layer_results = await asyncio.gather(
*[_wfs_intersects(layer, lat, lon) for layer in WFS_LAYERS],
return_exceptions=True,
)
areas: list[dict[str, Any]] = []
for layer, result in zip(WFS_LAYERS, layer_results):
if isinstance(result, Exception):
logger.warning("WFS warncell lookup failed for %s: %s", layer, result)
continue
areas.extend(result)
cell_ids: set[str] = set()
for area in areas:
cell_ids.update(_related_cell_ids(area["warncell_id"]))
payload = {
"lat": lat,
"lon": lon,
"areas": areas,
"cell_ids": sorted(cell_ids),
}
await cache_set(cache_key, payload, CACHE_TTL_GEOCODE)
return payload

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
from app.cache import cache_get, cache_set
from app.config import CACHE_TTL_WARNINGS, DWD_WARNINGS_URL
from app.database import set_cached_json
from app.http_client import get_client
from app.services.warncells import resolve_warncells
WARN_LEVELS = {
10: ("Vorabinformation", "info"),
20: ("Wetterwarnung", "minor"),
30: ("Markantes Wetter", "moderate"),
40: ("Unwetterwarnung", "severe"),
50: ("Extremes Unwetter", "extreme"),
60: ("Extremes Unwetter", "extreme"),
70: ("Extremes Unwetter", "extreme"),
80: ("Extremes Unwetter", "extreme"),
90: ("Extremes Unwetter", "extreme"),
}
def _flatten_warnings(payload: dict[str, Any]) -> list[dict[str, Any]]:
warnings_root = payload.get("warnings", {})
flattened: list[dict[str, Any]] = []
for cell_id, items in warnings_root.items():
if not isinstance(items, list):
continue
for item in items:
level = int(item.get("level", 0))
level_label, severity = WARN_LEVELS.get(level, (f"Level {level}", "unknown"))
flattened.append(
{
"cell_id": str(cell_id),
"state": item.get("state"),
"region_name": item.get("regionName"),
"event": item.get("event"),
"headline": item.get("headline"),
"description": (item.get("description") or "").strip(),
"instruction": (item.get("instruction") or "").strip(),
"level": level,
"level_label": level_label,
"severity": severity,
"type": item.get("type"),
"start": datetime.fromtimestamp(item["start"] / 1000, tz=timezone.utc).isoformat()
if item.get("start")
else None,
"end": datetime.fromtimestamp(item["end"] / 1000, tz=timezone.utc).isoformat()
if item.get("end")
else None,
}
)
flattened.sort(key=lambda w: (-w["level"], w.get("start") or ""))
return flattened
def _normalize_kreis_name(name: str) -> str:
value = name.lower().strip()
value = re.sub(r"^(landkreis|kreis und stadt|kreis|stadt)\s+", "", value)
value = re.sub(r"[^a-zäöüß0-9]+", " ", value)
return " ".join(value.split())
def _match_by_area_names(warning: dict[str, Any], area_names: list[str]) -> bool:
region = _normalize_kreis_name(warning.get("region_name") or "")
if not region:
return False
for area_name in area_names:
normalized = _normalize_kreis_name(area_name or "")
if not normalized or len(normalized) < 3:
continue
if region == normalized or normalized in region or region in normalized:
return True
return False
async def fetch_all_warnings() -> list[dict[str, Any]]:
cache_key = "warnings:all"
cached = await cache_get(cache_key)
if cached:
return cached
client = get_client()
response = await client.get(DWD_WARNINGS_URL, timeout=10.0)
response.raise_for_status()
text = response.text
if text.startswith("warnWetter.loadWarnings("):
text = text[text.index("(") + 1 : text.rindex(")")]
payload = json.loads(text)
warnings = _flatten_warnings(payload)
await cache_set(cache_key, warnings, CACHE_TTL_WARNINGS)
await set_cached_json("warnings_cache", {}, payload)
return warnings
async def fetch_warnings_for_location(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]:
warnings = await fetch_all_warnings()
warncells = await resolve_warncells(lat, lon)
cell_ids = set(warncells.get("cell_ids", []))
area_names = [area.get("name") for area in warncells.get("areas", []) if area.get("name")]
local = [
warning
for warning in warnings
if warning["cell_id"] in cell_ids or _match_by_area_names(warning, area_names)
]
if local_only:
result_warnings = local
else:
result_warnings = warnings[:100]
return {
"lat": lat,
"lon": lon,
"count_total": len(warnings),
"count_local": len(local),
"warnings": result_warnings,
"warncells": warncells.get("areas", []),
"matched_cell_ids": sorted(cell_ids),
"local_only": local_only,
"all_warnings_truncated": not local_only and len(warnings) > 100,
}

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Any
from app.cache import cache_get as redis_get, cache_set as redis_set
from app.config import (
BRIGHTSKY_BASE_URL,
CACHE_TTL_FORECAST,
CACHE_TTL_OBSERVATIONS,
OPEN_METEO_BASE_URL,
)
from app.database import get_cached_json, set_cached_json
from app.http_client import fetch_json
FORECAST_PARAMS = {
"timezone": "Europe/Berlin",
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,wind_gusts_10m",
"hourly": "temperature_2m,relative_humidity_2m,precipitation_probability,precipitation,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,wind_gusts_10m,visibility",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max,wind_gusts_10m_max,sunrise,sunset",
"forecast_days": 7,
}
def _coord_key(lat: float, lon: float) -> tuple[float, float]:
return round(lat, 4), round(lon, 4)
async def get_forecast(lat: float, lon: float) -> dict[str, Any]:
lat_k, lon_k = _coord_key(lat, lon)
redis_key = f"forecast:{lat_k}:{lon_k}"
cached = await redis_get(redis_key)
if cached:
return {"source": OPEN_METEO_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
cached = await get_cached_json("forecast_cache", {"lat": lat_k, "lon": lon_k}, CACHE_TTL_FORECAST)
if cached:
await redis_set(redis_key, cached, CACHE_TTL_FORECAST)
return {"source": OPEN_METEO_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
data = await fetch_json(
f"{OPEN_METEO_BASE_URL}/forecast",
params={"latitude": lat, "longitude": lon, **FORECAST_PARAMS},
)
await set_cached_json("forecast_cache", {"lat": lat_k, "lon": lon_k}, data)
await redis_set(redis_key, data, CACHE_TTL_FORECAST)
return {"source": OPEN_METEO_BASE_URL, "lat": lat, "lon": lon, "data": data, "cached": False}
async def get_observations(lat: float, lon: float) -> dict[str, Any]:
lat_k, lon_k = _coord_key(lat, lon)
redis_key = f"observations:{lat_k}:{lon_k}"
cached = await redis_get(redis_key)
if cached:
return {"source": BRIGHTSKY_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
cached = await get_cached_json("observations_cache", {"lat": lat_k, "lon": lon_k}, CACHE_TTL_OBSERVATIONS)
if cached:
await redis_set(redis_key, cached, CACHE_TTL_OBSERVATIONS)
return {"source": BRIGHTSKY_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
data = await fetch_json(f"{BRIGHTSKY_BASE_URL}/current_weather", params={"lat": lat, "lon": lon})
await set_cached_json("observations_cache", {"lat": lat_k, "lon": lon_k}, data)
await redis_set(redis_key, data, CACHE_TTL_OBSERVATIONS)
return {"source": BRIGHTSKY_BASE_URL, "lat": lat, "lon": lon, "data": data, "cached": False}

92
api/app/services/wms.py Normal file
View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree as ET
from app.http_client import get_client
from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL
NS = {"wms": "http://www.opengis.net/wms"}
def _parse_iso_duration_minutes(duration: str) -> int:
match = re.fullmatch(r"PT(\d+)M", duration.strip().upper())
if not match:
return 5
return int(match.group(1))
def _parse_time_dimension(value: str) -> tuple[datetime, datetime, int]:
start_raw, end_raw, step_raw = value.split("/")
start = datetime.fromisoformat(start_raw.replace("Z", "+00:00"))
end = datetime.fromisoformat(end_raw.replace("Z", "+00:00"))
step_minutes = _parse_iso_duration_minutes(step_raw)
return start, end, step_minutes
def _generate_time_steps(start: datetime, end: datetime, step_minutes: int) -> list[str]:
steps: list[str] = []
current = start
delta = timedelta(minutes=step_minutes)
while current <= end:
steps.append(current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"))
current += delta
return steps
def _find_layer_dimension(xml_text: str, layer_name: str) -> str | None:
root = ET.fromstring(xml_text)
target = layer_name.split(":")[-1]
for layer in root.iter():
if not layer.tag.endswith("Layer"):
continue
name_el = layer.find("wms:Name", NS) or layer.find("Name")
if name_el is None or name_el.text != target:
continue
for dim in layer:
if dim.tag.endswith("Dimension") and (dim.attrib.get("name") or "").lower() == "time":
return (dim.text or "").strip()
return None
async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict:
cache_key = f"wms:times:{layer}:{minutes}"
cached = await cache_get(cache_key)
if cached:
return cached
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
client = get_client()
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0)
response.raise_for_status()
dimension = _find_layer_dimension(response.text, layer)
if not dimension:
raise ValueError(f"No TIME dimension found for layer {layer}")
if "/" in dimension:
start, end, step_minutes = _parse_time_dimension(dimension)
all_steps = _generate_time_steps(start, end, step_minutes)
else:
all_steps = [item.strip() for item in dimension.split(",") if item.strip()]
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
filtered = []
for step in all_steps:
try:
ts = datetime.fromisoformat(step.replace("Z", "+00:00"))
except ValueError:
continue
if ts >= cutoff:
filtered.append(step)
payload = {
"layer": layer,
"minutes": minutes,
"step_minutes": _parse_iso_duration_minutes("PT5M") if "/" in dimension else 5,
"count": len(filtered),
"times": filtered,
"latest": filtered[-1] if filtered else None,
}
await cache_set(cache_key, payload, CACHE_TTL_WMS_TIMES)
return payload

8
api/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
httpx==0.28.1
pydantic==2.10.3
asyncpg==0.30.0
redis==5.2.1
python-jose[cryptography]==3.3.0
bcrypt==4.2.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Binary file not shown.

48
db/init.sql Normal file
View File

@@ -0,0 +1,48 @@
CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
CREATE TABLE IF NOT EXISTS forecast_cache (
lat NUMERIC(8, 4) NOT NULL,
lon NUMERIC(8, 4) NOT NULL,
data JSONB NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (lat, lon)
);
CREATE TABLE IF NOT EXISTS observations_cache (
lat NUMERIC(8, 4) NOT NULL,
lon NUMERIC(8, 4) NOT NULL,
data JSONB NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (lat, lon)
);
CREATE TABLE IF NOT EXISTS warnings_cache (
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
data JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS geocode_cache (
query_hash VARCHAR(64) PRIMARY KEY,
query_text TEXT NOT NULL,
results JSONB NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS radar_file_meta (
id SERIAL PRIMARY KEY,
product VARCHAR(16) NOT NULL,
filename VARCHAR(255) NOT NULL UNIQUE,
file_size BIGINT,
modified_at TIMESTAMPTZ,
render_status VARCHAR(24) NOT NULL DEFAULT 'pending',
render_path TEXT,
render_bounds JSONB,
render_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_radar_file_meta_product ON radar_file_meta (product, modified_at DESC);
CREATE INDEX IF NOT EXISTS idx_warnings_cache_fetched ON warnings_cache (fetched_at DESC);
SELECT create_hypertable('warnings_cache', 'fetched_at', if_not_exists => TRUE, migrate_data => TRUE);

View File

@@ -0,0 +1,49 @@
# Optional overlay for fully selfhosted forecast backends.
# Usage: docker compose -f docker-compose.yml -f docker-compose.selfhosted.yml --profile selfhosted up -d
#
# Open-Meteo and Bright Sky require significant storage and setup.
# See docs/NEXT_STEPS.md for hardware recommendations.
services:
open-meteo:
profiles: ["selfhosted"]
image: ghcr.io/open-meteo/open-meteo:latest
container_name: hexawetter-open-meteo
restart: unless-stopped
ports:
- "${OPEN_METEO_PORT:-8082}:8080"
volumes:
- open_meteo_data:/data
environment:
LOG_LEVEL: info
brightsky-postgres:
profiles: ["selfhosted"]
image: postgis/postgis:16-3.4
container_name: hexawetter-brightsky-postgres
restart: unless-stopped
environment:
POSTGRES_DB: brightsky
POSTGRES_USER: brightsky
POSTGRES_PASSWORD: ${BRIGHTSKY_DB_PASSWORD:-brightsky}
volumes:
- brightsky_postgres_data:/var/lib/postgresql/data
brightsky-redis:
profiles: ["selfhosted"]
image: redis:7-alpine
container_name: hexawetter-brightsky-redis
restart: unless-stopped
volumes:
- brightsky_redis_data:/data
api:
profiles: ["selfhosted"]
environment:
OPEN_METEO_BASE_URL: http://open-meteo:8080/v1
BRIGHTSKY_BASE_URL: http://host.docker.internal:${BRIGHTSKY_PORT:-8083}
volumes:
open_meteo_data:
brightsky_postgres_data:
brightsky_redis_data:

View File

@@ -0,0 +1,16 @@
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.hexawetter.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.hexawetter.entrypoints=websecure"
- "traefik.http.routers.hexawetter.tls=true"
- "traefik.http.routers.hexawetter.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
- "traefik.http.services.hexawetter.loadbalancer.server.port=80"
networks:
- default
- traefik
networks:
traefik:
external: true

96
docker-compose.yml Normal file
View File

@@ -0,0 +1,96 @@
services:
postgres:
image: timescale/timescaledb:latest-pg16
container_name: hexawetter-postgres
restart: unless-stopped
environment:
POSTGRES_DB: hexawetter
POSTGRES_USER: hexawetter
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hexawetter}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hexawetter -d hexawetter"]
interval: 10s
timeout: 5s
retries: 8
redis:
image: redis:7-alpine
container_name: hexawetter-redis
restart: unless-stopped
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 8
api:
build:
context: ./api
container_name: hexawetter-api
restart: unless-stopped
env_file:
- .env
environment:
DATABASE_URL: postgresql://hexawetter:${POSTGRES_PASSWORD:-hexawetter}@postgres:5432/hexawetter
REDIS_URL: redis://redis:6379/0
volumes:
- ./data:/data
ports:
- "${API_PORT:-8081}:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
frontend:
image: nginx:1.27-alpine
container_name: hexawetter-frontend
restart: unless-stopped
depends_on:
- api
volumes:
- ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "${WEB_PORT:-8080}:80"
radar-worker:
build:
context: ./worker
container_name: hexawetter-radar-worker
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/data
command: ["python", "worker.py"]
depends_on:
- api
radar-renderer:
build:
context: ./renderer
container_name: hexawetter-radar-renderer
restart: unless-stopped
env_file:
- .env
environment:
DATABASE_URL: postgresql://hexawetter:${POSTGRES_PASSWORD:-hexawetter}@postgres:5432/hexawetter
volumes:
- ./data:/data
depends_on:
postgres:
condition: service_healthy
radar-worker:
condition: service_started
volumes:
postgres_data:
redis_data:

37
docs/NEXT_STEPS.md Normal file
View File

@@ -0,0 +1,37 @@
# HexaWetter Ausbaustufen
## Erledigt in v1.0
- Leaflet-Karte mit DWD WMS (Radar + Warnungen)
- Radar-Zeitleiste mit Play/Pause (WMS TIME-Dimension)
- DWD-Warnungen mit Standortfilter
- Ortssuche (Nominatim), GPS, Favoriten (localStorage)
- PostgreSQL + TimescaleDB + Redis
- Lokaler Radar-Renderer (HDF5 → PNG)
- Healthchecks, Rate-Limits, CI, README, Impressum/Datenschutz
## Optional / später
### Open-Meteo vollständig selfhosten
```bash
docker compose -f docker-compose.yml -f docker-compose.selfhosted.yml --profile selfhosted up -d
```
Hardware: 4+ vCPU, 16 GB RAM, 150 GB+ NVMe (je nach Modellen).
### Bright Sky vollständig selfhosten
Eigener Stack mit PostgreSQL + Redis + Sync-Worker. Danach `BRIGHTSKY_BASE_URL` in `.env` setzen.
### PostGIS / erweiterte Geodaten
PostGIS-Extension aktivieren und Warncell-Polygone lokal cachen für präziseres Warn-Matching.
### Benachrichtigungen
Telegram/Discord-Webhooks über Umgebungsvariablen (`TELEGRAM_BOT_TOKEN`, `DISCORD_WEBHOOK_URL`) noch nicht implementiert.
### Eigene OSM-Kacheln
Tile-Server für vollständige Karten-Unabhängigkeit.

17
frontend/502.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>HexaWetter Dienst nicht erreichbar</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<main class="legal-page">
<h1>Dienst vorübergehend nicht erreichbar</h1>
<p>Die HexaWetter-API antwortet gerade nicht. Bitte später erneut versuchen oder den Stack prüfen:</p>
<pre>docker compose ps
docker compose logs api</pre>
<p><a href="/">Zur Startseite</a></p>
</main>
</body>
</html>

89
frontend/admin.html Normal file
View File

@@ -0,0 +1,89 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin HexaWetter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="style.css" />
</head>
<body class="admin-page">
<header class="site-header">
<div class="container header-inner">
<a class="brand-link" href="index.html">
<div class="brand-logo">H</div>
<div>
<div class="brand-sub">HexaWetter</div>
<div class="brand-title">Admin</div>
</div>
</a>
<div class="header-actions">
<a href="index.html" class="btn btn-secondary">Zurück</a>
<button id="logoutBtn" class="btn btn-ghost admin-hidden">Abmelden</button>
</div>
</div>
</header>
<main class="app-main">
<div class="container">
<section id="loginSection" class="admin-login-wrap">
<div class="glass-card admin-login-card">
<h1>Admin Login</h1>
<p class="muted">Geschützter Bereich für Service-Status und Einstellungen.</p>
<form id="loginForm">
<label>Benutzername<input id="adminUser" autocomplete="username" required /></label>
<label>Passwort<input id="adminPass" type="password" autocomplete="current-password" required minlength="8" /></label>
<button class="btn btn-primary full-width" type="submit" style="margin-top:12px">Anmelden</button>
</form>
<p id="loginError" class="admin-error"></p>
</div>
</section>
<section id="dashboardSection" class="admin-hidden">
<div class="admin-toolbar">
<div>
<h1>Dashboard</h1>
<p class="muted" id="adminTime"></p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button id="refreshBtn" class="btn btn-secondary">Aktualisieren</button>
<button id="clearCacheBtn" class="btn btn-secondary">Redis-Cache leeren</button>
</div>
</div>
<section class="admin-grid" id="infraGrid"></section>
<section class="glass-card" style="margin-top:16px">
<h2>Dienste & Datenquellen</h2>
<div class="source-status-grid" id="serviceGrid"></div>
</section>
<section class="glass-card">
<h2>Speicher</h2>
<div class="admin-grid" id="storageGrid"></div>
</section>
<section class="glass-card">
<h2>Konfiguration</h2>
<pre class="config-block" id="configBlock"></pre>
</section>
<section class="glass-card">
<h2>Runtime-Einstellungen</h2>
<form id="settingsForm" class="settings-grid">
<label>Rate-Limit / Minute<input id="setRateLimit" type="number" min="10" max="1000" /></label>
<label>Cache Forecast (s)<input id="setCacheForecast" type="number" min="60" max="86400" /></label>
<label>Cache Observations (s)<input id="setCacheObs" type="number" min="60" max="86400" /></label>
<label>Cache Warnings (s)<input id="setCacheWarn" type="number" min="30" max="3600" /></label>
<label>Radar-Retention (h)<input id="setRadarRetention" type="number" min="6" max="168" /></label>
<div><button class="btn btn-primary full-width" type="submit">Speichern</button></div>
</form>
<p class="muted" id="settingsMsg" style="margin-top:10px"></p>
</section>
</section>
</div>
</main>
<script src="admin.js"></script>
</body>
</html>

169
frontend/admin.js Normal file
View File

@@ -0,0 +1,169 @@
const TOKEN_KEY = "hexawetter_admin_token";
const el = (id) => document.getElementById(id);
function getToken() {
return sessionStorage.getItem(TOKEN_KEY);
}
function setToken(token) {
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
}
async function api(path, options = {}) {
const headers = { ...(options.headers || {}) };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
setToken(null);
showLogin();
throw new Error("Sitzung abgelaufen");
}
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let v = bytes, u = 0;
while (v >= 1024 && u < units.length - 1) { v /= 1024; u++; }
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`;
}
function showLogin() {
el("loginSection").classList.remove("admin-hidden");
el("dashboardSection").classList.add("admin-hidden");
el("logoutBtn").classList.add("admin-hidden");
}
function showDashboard() {
el("loginSection").classList.add("admin-hidden");
el("dashboardSection").classList.remove("admin-hidden");
el("logoutBtn").classList.remove("admin-hidden");
}
function renderServices(services) {
el("serviceGrid").innerHTML = (services || []).map((s) => `
<div class="source-status ${s.status}">
<strong>${s.name}</strong>
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
${s.http_status ? `<small>HTTP ${s.http_status}</small>` : ""}
${s.error ? `<small>${s.error}</small>` : ""}
</div>
`).join("");
}
function renderStorage(storage) {
el("storageGrid").innerHTML = Object.entries(storage || {}).map(([key, info]) => `
<div class="admin-stat">
<strong>${formatBytes(info.bytes || 0)}</strong>
<span>${key}: ${info.files || 0} Dateien</span>
</div>
`).join("");
}
function renderInfra(infra) {
el("infraGrid").innerHTML = Object.entries(infra || {}).map(([key, status]) => `
<div class="admin-stat">
<strong>${status}</strong>
<span>${key}</span>
</div>
`).join("");
}
async function loadDashboard() {
const data = await api("/api/admin/dashboard");
el("adminTime").textContent = `Stand: ${new Date(data.time_utc).toLocaleString("de-DE")}`;
renderInfra(data.infrastructure);
renderServices(data.services);
renderStorage(data.storage);
el("configBlock").textContent = JSON.stringify(data.config, null, 2);
const settings = await api("/api/admin/settings");
const defaults = settings.defaults;
const overrides = settings.runtime_overrides || {};
el("setRateLimit").value = overrides.rate_limit_per_minute ?? defaults.rate_limit_per_minute;
el("setCacheForecast").value = overrides.cache_ttl_forecast ?? defaults.cache_ttl.forecast;
el("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations;
el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings;
el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours;
}
el("loginForm").addEventListener("submit", async (e) => {
e.preventDefault();
el("loginError").textContent = "";
try {
const data = await api("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: el("adminUser").value, password: el("adminPass").value }),
});
setToken(data.access_token);
showDashboard();
await loadDashboard();
} catch (err) {
el("loginError").textContent = err.message.includes("401") ? "Ungültige Zugangsdaten" : err.message;
}
});
el("logoutBtn").addEventListener("click", () => {
setToken(null);
showLogin();
});
el("refreshBtn").addEventListener("click", () => loadDashboard().catch((e) => alert(e.message)));
el("clearCacheBtn").addEventListener("click", async () => {
if (!confirm("Redis-Cache wirklich leeren?")) return;
await api("/api/admin/cache/clear", { method: "POST" });
alert("Cache geleert");
await loadDashboard();
});
el("settingsForm").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = {
rate_limit_per_minute: Number(el("setRateLimit").value),
cache_ttl_forecast: Number(el("setCacheForecast").value),
cache_ttl_observations: Number(el("setCacheObs").value),
cache_ttl_warnings: Number(el("setCacheWarn").value),
radar_retention_hours: Number(el("setRadarRetention").value),
};
try {
await api("/api/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
el("settingsMsg").textContent = "Einstellungen in Redis gespeichert.";
} catch (err) {
el("settingsMsg").textContent = err.message;
}
});
async function init() {
try {
const status = await fetch("/api/admin/status").then((r) => r.json());
if (!status.admin_enabled) {
el("loginError").textContent = "Admin ist nicht konfiguriert. Bitte ADMIN_PASSWORD_HASH und ADMIN_JWT_SECRET in .env setzen.";
return;
}
} catch { /* ignore */ }
if (getToken()) {
try {
showDashboard();
await loadDashboard();
return;
} catch {
setToken(null);
}
}
showLogin();
}
init();

671
frontend/app.js Normal file
View File

@@ -0,0 +1,671 @@
const STORAGE_KEY = "hexawetter_settings";
const FAV_KEY = "hexawetter_favorites";
const el = (id) => document.getElementById(id);
let latestForecast = null;
let map = null;
let mapMarker = null;
let wmsLayers = [];
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 settings = loadSettings();
let searchTimer = null;
const WEATHER_ICONS = {
0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️", 45: "🌫️", 48: "🌫️",
51: "🌦️", 53: "🌦️", 55: "🌧️", 61: "🌧️", 63: "🌧️", 65: "🌧️",
71: "🌨️", 73: "🌨️", 75: "❄️", 80: "🌦️", 81: "🌧️", 82: "⛈️",
95: "⛈️", 96: "⛈️", 99: "⛈️",
};
function loadSettings() {
try {
return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") };
} catch { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120 }; }
}
function saveSettings() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
function loadFavorites() {
try { return JSON.parse(localStorage.getItem(FAV_KEY) || "[]"); } catch { return []; }
}
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",
45: "Nebel", 48: "Reifnebel", 51: "Leichter Niesel", 53: "Niesel",
55: "Starker Niesel", 61: "Leichter Regen", 63: "Regen", 65: "Starker Regen",
71: "Leichter Schnee", 73: "Schnee", 75: "Starker Schnee", 80: "Regenschauer",
81: "Starke Schauer", 82: "Heftige Schauer", 95: "Gewitter", 96: "Gewitter mit Hagel", 99: "Schweres Gewitter",
};
return `${WEATHER_ICONS[code] || ""} ${map[code] || `Code ${code}`}`.trim();
}
function convertTemp(c) {
if (settings.unitTemp === "fahrenheit") return `${Math.round(c * 9 / 5 + 32)} °F`;
return `${Math.round(c)} °C`;
}
function convertWind(kmh) {
if (settings.unitWind === "ms") return `${(kmh / 3.6).toFixed(1)} m/s`;
if (settings.unitWind === "kn") return `${(kmh * 0.539957).toFixed(1)} kn`;
return `${Math.round(kmh)} km/h`;
}
async function api(path, options = {}) {
const res = await fetch(path, options);
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
function getLocation() {
return {
place: el("place").value || "Standort",
lat: Number(el("lat").value),
lon: Number(el("lon").value),
};
}
function setLocation(loc) {
el("place").value = loc.place;
el("lat").value = loc.lat;
el("lon").value = loc.lon;
}
function formatDateTime(value) {
return new Date(value).toLocaleString("de-DE", {
weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
});
}
function metric(label, value, hint = "") {
return `<div class="metric"><span>${label}</span><strong>${value}</strong>${hint ? `<small>${hint}</small>` : ""}</div>`;
}
function warnLevelClass(level) {
if (level >= 50) return "warn-extreme";
if (level >= 40) return "warn-severe";
if (level >= 30) return "warn-moderate";
if (level >= 20) return "warn-minor";
return "warn-info";
}
async function loadHealth() {
try {
const data = await api("/api/health");
el("health").textContent = `API: online · v${data.version} · ${new Date(data.time_utc).toLocaleTimeString("de-DE")}`;
el("health").classList.remove("bad");
} catch {
el("health").textContent = "API: offline";
el("health").classList.add("bad");
}
}
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) {
latestForecast = dash.forecast;
const current = dash.forecast.data.current;
el("currentTemp").textContent = convertTemp(current.temperature_2m);
el("currentMeta").textContent = `${weatherText(current.weather_code)} · Wind ${convertWind(current.wind_speed_10m)} · ${loc.place}`;
el("forecastSource").textContent = dash.forecast.cached ? `${dash.forecast.source} (Cache)` : dash.forecast.source;
el("dailySource").textContent = dash.forecast.cached ? `${dash.forecast.source} (Cache)` : dash.forecast.source;
el("currentDetails").innerHTML = [
metric("Gefühlt", convertTemp(current.apparent_temperature)),
metric("Luftfeuchte", `${current.relative_humidity_2m} %`),
metric("Luftdruck", `${Math.round(current.pressure_msl)} hPa`),
metric("Niederschlag", `${current.precipitation ?? 0} mm`),
metric("Bewölkung", `${current.cloud_cover ?? 0} %`),
metric("Böen", convertWind(current.wind_gusts_10m ?? 0)),
].join("");
renderDaily(dash.forecast.data.daily);
renderHourly(dash.forecast.data.hourly);
renderHourStrip(dash.forecast.data.hourly);
}
if (dash.observations?.data) {
const weather = dash.observations.data.weather;
const source = dash.observations.data.sources?.[0];
if (weather) {
el("obsTemp").textContent = convertTemp(weather.temperature);
el("obsMeta").textContent = `${source?.station_name || "DWD Station"} · ${weather.timestamp || ""}`;
}
}
if (dash.radar) {
el("radarCount").textContent = `${dash.radar.count} online`;
el("radarMeta").textContent = `Produkt ${dash.radar.product?.toUpperCase() || "RV"} · DWD OpenData`;
}
if (dash.warnings) {
applyWarningsData(dash.warnings);
}
}
function applyWarningsData(data) {
const localOnly = el("warningsLocalOnly")?.checked ?? true;
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>`;
}
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}`);
latestForecast = data;
const current = data.data.current;
el("currentTemp").textContent = convertTemp(current.temperature_2m);
el("currentMeta").textContent = `${weatherText(current.weather_code)} · Wind ${convertWind(current.wind_speed_10m)} · ${loc.place}`;
el("forecastSource").textContent = data.cached ? `${data.source} (Cache)` : data.source;
el("dailySource").textContent = data.cached ? `${data.source} (Cache)` : data.source;
el("currentDetails").innerHTML = [
metric("Gefühlt", convertTemp(current.apparent_temperature)),
metric("Luftfeuchte", `${current.relative_humidity_2m} %`),
metric("Luftdruck", `${Math.round(current.pressure_msl)} hPa`),
metric("Niederschlag", `${current.precipitation ?? 0} mm`),
metric("Bewölkung", `${current.cloud_cover ?? 0} %`),
metric("Böen", convertWind(current.wind_gusts_10m ?? 0)),
].join("");
renderDaily(data.data.daily);
renderHourly(data.data.hourly);
renderHourStrip(data.data.hourly);
}
function renderDaily(daily) {
el("dailyForecast").innerHTML = daily.time.map((date, idx) => {
const d = new Date(date + "T12:00:00");
const day = d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
const sunrise = daily.sunrise?.[idx] ? new Date(daily.sunrise[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "";
const sunset = daily.sunset?.[idx] ? new Date(daily.sunset[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "";
return `<div class="day">
<strong>${day}</strong>
<span class="muted">${weatherText(daily.weather_code[idx])}</span>
<p class="temp">${convertTemp(daily.temperature_2m_max[idx])} / ${convertTemp(daily.temperature_2m_min[idx])}</p>
<span class="muted">Regen: ${daily.precipitation_sum[idx]} mm · ${daily.precipitation_probability_max[idx] ?? 0}%</span>
<span class="muted block">Wind: ${convertWind(daily.wind_speed_10m_max[idx] ?? 0)} · Böen ${convertWind(daily.wind_gusts_10m_max[idx] ?? 0)}</span>
<span class="muted block">Sonne: ${sunrise} ${sunset}</span>
</div>`;
}).join("");
}
function renderHourly(hourly) {
el("hourlyTable").innerHTML = hourly.time.slice(0, 48).map((time, idx) => `
<tr>
<td>${formatDateTime(time)}</td>
<td>${weatherText(hourly.weather_code[idx])}</td>
<td>${convertTemp(hourly.temperature_2m[idx])}</td>
<td>${hourly.precipitation_probability[idx] ?? 0}% · ${hourly.precipitation[idx] ?? 0} mm</td>
<td>${hourly.cloud_cover[idx] ?? 0}%</td>
<td>${convertWind(hourly.wind_speed_10m[idx] ?? 0)}</td>
<td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td>
</tr>
`).join("");
}
function renderHourStrip(hourly) {
el("hourStrip").innerHTML = hourly.time.slice(0, 12).map((time, idx) => {
const label = new Date(time).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const rain = hourly.precipitation_probability[idx] ?? 0;
return `<div class="hour-card">
<strong>${label}</strong>
<span>${convertTemp(hourly.temperature_2m[idx])}</span>
<small>${weatherText(hourly.weather_code[idx])}</small>
<div class="bar"><i style="width:${Math.min(rain, 100)}%"></i></div>
<small>Regen ${rain}% · Wind ${convertWind(hourly.wind_speed_10m[idx] ?? 0)}</small>
</div>`;
}).join("");
}
async function loadObservations() {
const loc = getLocation();
try {
const data = await api(`/api/observations?lat=${loc.lat}&lon=${loc.lon}`);
const weather = data.data.weather;
const source = data.data.sources?.[0];
if (!weather) throw new Error("Keine Beobachtung gefunden");
el("obsTemp").textContent = convertTemp(weather.temperature);
el("obsMeta").textContent = `${source?.station_name || "DWD Station"} · ${weather.timestamp || ""}`;
} catch (err) {
el("obsTemp").textContent = "";
el("obsMeta").textContent = `Keine DWD-Beobachtung: ${err.message}`;
}
}
async function loadWarnings() {
const loc = getLocation();
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>`;
} catch (err) {
el("warnCount").textContent = "";
el("warnMeta").textContent = err.message;
el("warningsList").innerHTML = `<p class="muted">Warnungen nicht verfügbar: ${err.message}</p>`;
el("warncellInfo")?.classList.add("hidden");
}
}
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));
const time = radarTimes[radarFrameIndex];
el("radarSlider").value = radarFrameIndex;
el("radarTimeLabel").textContent = formatDateTime(time);
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
if (el("useLocalRadar").checked && renderedFrames.length) {
showLocalRadarFrame(radarFrameIndex);
} else 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;
try {
const data = await api(`/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}`);
radarTimes = data.times || [];
el("radarSlider").max = Math.max(0, radarTimes.length - 1);
if (radarTimes.length) setRadarTime(radarTimes.length - 1);
else el("radarTimeLabel").textContent = "Keine Zeitschritte";
} catch (err) {
el("radarTimeLabel").textContent = `Zeitleiste: ${err.message}`;
}
}
function toggleRadarPlay() {
if (radarPlaying) {
clearInterval(radarPlayTimer);
radarPlayTimer = null;
radarPlaying = false;
el("radarPlayBtn").textContent = "▶";
return;
}
if (radarTimes.length < 2) return;
radarPlaying = true;
el("radarPlayBtn").textContent = "⏸";
radarPlayTimer = setInterval(() => {
const next = radarFrameIndex + 1;
if (next >= radarTimes.length) setRadarTime(0);
else setRadarTime(next);
}, 800);
}
async function initMap() {
if (map || typeof L === "undefined") return;
const loc = getLocation();
map = L.map("map", { zoomControl: true }).setView([loc.lat, loc.lon], 8);
osmLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; OpenStreetMap-Mitwirkende",
}).addTo(map);
mapMarker = L.marker([loc.lat, loc.lon]).addTo(map).bindPopup(loc.place);
try {
const config = await api("/api/radar/wms");
const overlays = {};
wmsLayers = config.layers.map((layer) => {
const wms = L.tileLayer.wms(config.service_url, {
layers: layer.layer,
format: "image/png",
transparent: true,
version: "1.1.1",
opacity: layer.opacity ?? 0.7,
attribution: config.attribution,
});
overlays[layer.title] = wms;
if (layer.id === "niederschlagsradar") radarLayer = wms;
if (layer.id === "warnungen_gemeinden") warningLayer = wms;
if (layer.enabled) wms.addTo(map);
return { ...layer, instance: wms };
});
layerControl = L.control.layers({ "OpenStreetMap": osmLayer }, overlays, { collapsed: false }).addTo(map);
el("mapMeta").textContent = "DWD Radar + Warnungen aktiv";
await loadRadarTimes();
} catch (err) {
el("mapMeta").textContent = `Karte: ${err.message}`;
}
}
function updateMapLocation() {
if (!map || !mapMarker) return;
const loc = getLocation();
map.setView([loc.lat, loc.lon], Math.max(map.getZoom(), 8));
mapMarker.setLatLng([loc.lat, loc.lon]).bindPopup(loc.place);
}
function reloadRadarLayers() {
wmsLayers.forEach((layer) => {
if (map?.hasLayer(layer.instance)) layer.instance.setParams({ _cache: Date.now() });
});
loadRadarTimes();
}
function toggleWarningsOnMap() {
if (!warningLayer || !map) return;
if (el("showWarningsOnMap").checked) warningLayer.addTo(map);
else map.removeLayer(warningLayer);
}
async function searchPlaces(query) {
if (query.length < 2) {
el("searchResults").classList.add("hidden");
return;
}
try {
const data = await api(`/api/geocode/search?q=${encodeURIComponent(query)}`);
const results = data.results || [];
el("searchResults").innerHTML = results.length ? results.map((r, i) => `
<li role="option" data-index="${i}">
<strong>${r.place}</strong>
<span>${r.label}</span>
</li>
`).join("") : `<li class="muted">Keine Treffer</li>`;
el("searchResults").classList.remove("hidden");
el("searchResults").querySelectorAll("li[data-index]").forEach((item) => {
item.addEventListener("click", () => {
const r = results[Number(item.dataset.index)];
setLocation({ place: r.place, lat: r.lat, lon: r.lon });
el("searchResults").classList.add("hidden");
loadAll();
});
});
} catch {
el("searchResults").innerHTML = `<li class="muted">Suche fehlgeschlagen</li>`;
el("searchResults").classList.remove("hidden");
}
}
function renderFavorites() {
const favs = loadFavorites();
const panel = el("favoritesPanel");
if (!favs.length) { panel.classList.add("hidden"); return; }
panel.classList.remove("hidden");
el("favoritesList").innerHTML = favs.map((f, i) => `
<button class="chip" data-index="${i}">${f.place}</button>
`).join("");
el("favoritesList").querySelectorAll(".chip").forEach((btn) => {
btn.addEventListener("click", () => {
const f = favs[Number(btn.dataset.index)];
setLocation(f);
loadAll();
});
});
}
function addFavorite() {
const loc = getLocation();
const favs = loadFavorites().filter((f) => f.place !== loc.place || f.lat !== loc.lat);
favs.unshift(loc);
saveFavorites(favs.slice(0, 8));
renderFavorites();
}
function useGeolocation() {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(async (pos) => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
try {
const data = await api(`/api/geocode/reverse?lat=${lat}&lon=${lon}`);
setLocation({ place: data.result?.place || "GPS-Standort", lat, lon });
} catch {
setLocation({ place: "GPS-Standort", lat, lon });
}
loadAll();
});
}
function applyTheme() {
document.documentElement.dataset.theme = settings.theme;
el("themeToggle").textContent = settings.theme === "dark" ? "🌙" : "☀️";
}
function applySettingsToUI() {
el("unitTemp").value = settings.unitTemp;
el("unitWind").value = settings.unitWind;
el("radarMinutes").value = String(settings.radarMinutes);
applyTheme();
}
async function loadDefaultLocation() {
const stored = settings.defaultLocation;
if (stored) { setLocation(stored); return; }
try {
const data = await api("/api/location/default");
setLocation(data);
} catch { /* fallback values in HTML */ }
}
function activateView(name) {
document.querySelectorAll(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.view === 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();
}
async function loadAll() {
await loadHealth();
await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]);
updateMapLocation();
if (map) loadRadarTimes();
}
async function init() {
applySettingsToUI();
await loadDefaultLocation();
renderFavorites();
await loadAll();
setInterval(loadHealth, 30000);
}
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));
el("radarFirstBtn").addEventListener("click", () => setRadarTime(0));
el("radarLastBtn").addEventListener("click", () => setRadarTime(radarTimes.length - 1));
el("radarSlider").addEventListener("input", (e) => setRadarTime(Number(e.target.value)));
el("placeSearch").addEventListener("input", (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => searchPlaces(e.target.value.trim()), 300);
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".search-wrap")) el("searchResults").classList.add("hidden");
});
el("themeToggle").addEventListener("click", () => {
settings.theme = settings.theme === "dark" ? "light" : "dark";
saveSettings();
applyTheme();
});
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => {
el(id).addEventListener("change", (e) => {
const key = id === "radarMinutes" ? "radarMinutes" : id.replace("unit", "unit").replace("Temp", "Temp").replace("Wind", "Wind");
if (id === "unitTemp") settings.unitTemp = e.target.value;
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 (id === "radarMinutes" && map) loadRadarTimes();
});
});
el("saveDefaultLocationBtn").addEventListener("click", () => {
settings.defaultLocation = getLocation();
saveSettings();
el("saveDefaultLocationBtn").textContent = "Gespeichert ✓";
setTimeout(() => { el("saveDefaultLocationBtn").textContent = "Aktuellen Ort als Standard speichern"; }, 2000);
});
init();

29
frontend/datenschutz.html Normal file
View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Datenschutz HexaWetter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="legal-page">
<p><a href="index.html">← Zurück zu HexaWetter</a></p>
<h1>Datenschutzerklärung</h1>
<h2>1. Verantwortlicher</h2>
<p>Verantwortlich ist der Betreiber dieser selfhosted HexaWetter-Instanz (siehe Impressum).</p>
<h2>2. Verarbeitete Daten</h2>
<ul>
<li>Standortkoordinaten und Suchbegriffe werden an die eigene API und ggf. externe Dienste (Nominatim, DWD, Forecast-APIs) übermittelt.</li>
<li>Einstellungen und Favoriten werden lokal im Browser (localStorage) gespeichert.</li>
<li>Server-Logs können IP-Adresse, Zeitstempel und angefragte URLs enthalten.</li>
</ul>
<h2>3. Externe Dienste</h2>
<p>Je nach Konfiguration werden Daten an Open-Meteo, Bright Sky, DWD, OpenStreetMap/Nominatim und DWD WMS übertragen. Bitte die Datenschutzhinweise der jeweiligen Anbieter beachten.</p>
<h2>4. Speicherdauer</h2>
<p>API-Caches (PostgreSQL/Redis) und Radar-Rohdateien werden nach konfigurierter Retention gelöscht.</p>
<h2>5. Ihre Rechte</h2>
<p>Sie haben Rechte auf Auskunft, Berichtigung und Löschung personenbezogener Daten beim Verantwortlichen.</p>
</main>
</body>
</html>

26
frontend/impressum.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Impressum HexaWetter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="legal-page">
<p><a href="index.html">← Zurück zu HexaWetter</a></p>
<h1>Impressum</h1>
<p>Angaben gemäß § 5 TMG (Muster bitte für den produktiven Betrieb anpassen):</p>
<p><strong>HexaHost / HexaWetter</strong><br />
Musterstraße 1<br />
94051 Hauzenberg<br />
Deutschland</p>
<p><strong>Kontakt:</strong> wetter@example.com</p>
<p><strong>Verantwortlich für den Inhalt:</strong> Betreiber dieser selfhosted Instanz</p>
<h2>Haftungsausschluss Wetterdaten</h2>
<p>HexaWetter aggregiert Daten aus öffentlichen Quellen (DWD, Open-Meteo-kompatible APIs, OpenStreetMap). Es handelt sich nicht um eine amtliche Wetterberatung des Deutschen Wetterdienstes.</p>
<h2>Quellen</h2>
<p>Deutscher Wetterdienst (DWD), Open Data · Open-Meteo-kompatible Forecast-API · Bright Sky-kompatible Stationsdaten · OpenStreetMap-Mitwirkende</p>
</main>
</body>
</html>

218
frontend/index.html Normal file
View File

@@ -0,0 +1,218 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="HexaWetter Selfhosted Wetterportal mit DWD Radar, Warnungen und Forecast" />
<title>HexaWetter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a class="brand-link" href="index.html">
<div class="brand-logo" aria-hidden="true">H</div>
<div>
<div class="brand-sub">HexaHost · Selfhosted Weather</div>
<div class="brand-title">HexaWetter</div>
</div>
</a>
<div class="header-actions">
<div class="status-pill" id="health">API: prüfe…</div>
<button id="themeToggle" class="btn btn-ghost icon-btn" title="Theme wechseln" aria-label="Theme wechseln">🌙</button>
<a href="admin.html" class="btn btn-secondary">Admin</a>
</div>
</div>
</header>
<main class="app-main">
<div class="container">
<section class="hero-copy">
<h1>Wetter für deinen Standort</h1>
<p>Forecast, DWD-Beobachtungen, interaktive Radar-Karte mit Zeitleiste und standortgenaue Warnungen gehostet auf deiner Infrastruktur.</p>
</section>
<section class="glass-card controls">
<div class="search-wrap">
<label for="placeSearch">Ortssuche / PLZ</label>
<input id="placeSearch" type="search" placeholder="z.B. Hauzenberg, 94051 Passau…" autocomplete="off" />
<ul id="searchResults" class="search-results hidden" role="listbox"></ul>
</div>
<div>
<label for="place">Ort / Label</label>
<input id="place" value="Hauzenberg" />
</div>
<div>
<label for="lat">Latitude</label>
<input id="lat" type="number" step="0.0001" value="48.6546" />
</div>
<div>
<label for="lon">Longitude</label>
<input id="lon" type="number" step="0.0001" value="13.6252" />
</div>
<div class="control-actions">
<button id="geoBtn" class="btn btn-secondary icon-btn" title="Browser-Standort">📍</button>
<button id="favBtn" class="btn btn-secondary icon-btn" title="Favorit"></button>
<button id="loadBtn" class="btn btn-primary">Aktualisieren</button>
</div>
</section>
<section class="glass-card favorites-panel hidden" id="favoritesPanel">
<div class="section-head">
<h2>Gespeicherte Orte</h2>
<span class="muted">Klicken zum Laden</span>
</div>
<div id="favoritesList" class="chip-row"></div>
</section>
<nav class="tabs" aria-label="Ansichten">
<button class="tab active" data-view="dashboard">Übersicht</button>
<button class="tab" data-view="radar">Radar-Karte</button>
<button class="tab" data-view="warnings">Warnungen</button>
<button class="tab" data-view="hourly">Stunden</button>
<button class="tab" data-view="daily">7 Tage</button>
<button class="tab" data-view="raw">Rohdaten</button>
<button class="tab" data-view="settings">Einstellungen</button>
</nav>
<section class="view active" data-view="dashboard">
<section class="grid">
<article class="glass-card card"><h2>Aktuelles Wetter</h2><p class="big" id="currentTemp"></p><p id="currentMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>DWD-Beobachtung</h2><p class="big" id="obsTemp"></p><p id="obsMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Radar-Status</h2><p class="big" id="radarCount"></p><p id="radarMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Warnungen</h2><p class="big" id="warnCount"></p><p id="warnMeta" class="muted">Lade…</p></article>
</section>
<section class="glass-card">
<div class="section-head"><h2>Details aktuell</h2><span class="muted" id="forecastSource"></span></div>
<div class="metrics" id="currentDetails"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Kurzvorschau</h2><span class="muted">Nächste Stunden</span></div>
<div class="hour-strip" id="hourStrip"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen-Status</h2><span class="muted" id="sourceStatusSummary">Prüfe…</span></div>
<div class="source-status-grid" id="sourceStatusGrid"></div>
</section>
</section>
<section class="view" data-view="radar">
<section class="glass-card map-panel">
<div class="section-head">
<div><h2>Radar-Karte</h2><p class="muted no-margin">DWD WMS · OpenStreetMap · Zeitleiste</p></div>
<span class="muted" id="mapMeta">Lade…</span>
</div>
<div id="map"></div>
<div class="panel-inner" id="radarTimeline">
<div class="section-head"><strong>Radar-Zeitleiste</strong><span class="muted" id="radarTimeLabel"></span></div>
<div class="timeline-controls">
<button id="radarFirstBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarPrevBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarPlayBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarNextBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarLastBtn" class="btn btn-secondary icon-btn"></button>
<input id="radarSlider" type="range" min="0" max="0" value="0" />
</div>
<div class="timeline-meta">
<label class="inline-check"><input type="checkbox" id="useLocalRadar" /> Lokale Render-Frames</label>
<label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
<span class="muted" id="radarFrameInfo"></span>
</div>
</div>
<div class="map-actions">
<button id="centerMapBtn" class="btn btn-secondary">Auf Standort zentrieren</button>
<button id="reloadRadarLayersBtn" class="btn btn-secondary">Layer neu laden</button>
</div>
</section>
</section>
<section class="view" data-view="warnings">
<section class="glass-card">
<div class="section-head">
<div>
<h2>Wetterwarnungen</h2>
<p class="muted no-margin">Standortgenau via DWD-Warncell-IDs (WFS)</p>
</div>
<label class="inline-check"><input type="checkbox" id="warningsLocalOnly" checked /> Nur Standort</label>
</div>
<div id="warncellInfo" class="warncell-info hidden"></div>
<div id="warningsList" class="warnings-list"></div>
</section>
</section>
<section class="view" data-view="hourly">
<section class="glass-card">
<div class="section-head"><h2>Stundenansicht</h2><span class="muted">48 Stunden</span></div>
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr></thead><tbody id="hourlyTable"></tbody></table></div>
</section>
</section>
<section class="view" data-view="daily">
<section class="glass-card">
<div class="section-head"><h2>7-Tage Forecast</h2><span class="muted" id="dailySource"></span></div>
<div class="days" id="dailyForecast"></div>
</section>
</section>
<section class="view" data-view="raw">
<section class="glass-card">
<div class="section-head">
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">DWD OpenData · lokaler Renderer</p></div>
<button id="syncRadarBtn" class="btn btn-secondary">Sync starten</button>
</div>
<div class="table-wrap"><table><thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th>Download</th></tr></thead><tbody id="radarFiles"></tbody></table></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Gerenderte Frames</h2><span class="muted" id="renderedCount"></span></div>
<div class="rendered-grid" id="renderedFrames"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen</h2></div>
<div class="source-list" id="sourceList"></div>
</section>
</section>
<section class="view" data-view="settings">
<section class="glass-card">
<h2>Einstellungen</h2>
<div class="settings-grid">
<label>Temperatureinheit<select id="unitTemp"><option value="celsius">°C</option><option value="fahrenheit">°F</option></select></label>
<label>Windeinheit<select id="unitWind"><option value="kmh">km/h</option><option value="ms">m/s</option><option value="kn">kn</option></select></label>
<label>Radar-Zeitleiste<select id="radarMinutes"><option value="60">60 Min.</option><option value="90">90 Min.</option><option value="120" selected>120 Min.</option></select></label>
<label>Standard-Standort<button id="saveDefaultLocationBtn" class="btn btn-secondary full-width">Aktuellen Ort speichern</button></label>
</div>
</section>
</section>
</div>
</main>
<footer class="site-footer">
<div class="container footer-grid">
<div>
<strong>HexaWetter</strong>
<p>Selfhosted Wetterportal von HexaHost. Keine amtliche Wetterberatung.</p>
<p class="muted">Daten: DWD Open Data · Open-Meteo · Bright Sky · OpenStreetMap</p>
</div>
<div>
<strong>Rechtliches</strong>
<ul>
<li><a href="impressum.html">Impressum</a></li>
<li><a href="datenschutz.html">Datenschutz</a></li>
</ul>
</div>
<div>
<strong>System</strong>
<ul>
<li><a href="admin.html">Admin-Bereich</a></li>
<li><a href="/api/health" target="_blank" rel="noopener">API Health</a></li>
</ul>
</div>
</div>
</footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="app.js"></script>
</body>
</html>

471
frontend/style.css Normal file
View File

@@ -0,0 +1,471 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Russo+One&display=swap");
:root {
--background-color: #0d0821;
--background-gradient: linear-gradient(135deg, #0d0821 0%, #1a1230 100%);
--primary-color: #ff51f9;
--accent-color-1: #a348ff;
--accent-color-2: #3978ff;
--highlight-color: #f093ff;
--text-primary: #ffffff;
--text-secondary: #cfc9dd;
--success-color: #32fba2;
--warning-color: #ffcc00;
--error-color: #ff4d6d;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.12);
--glass-shadow: 0 8px 32px rgba(255, 81, 249, 0.12);
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.25rem;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 3rem;
--warn-info: #7ec8ff;
--warn-minor: #ffd166;
--warn-moderate: #ff9f43;
--warn-severe: #ff6b6b;
--warn-extreme: #c9184a;
}
html[data-theme="light"] {
--background-color: #f4f0ff;
--background-gradient: linear-gradient(135deg, #f4f0ff 0%, #ebe4ff 100%);
--text-primary: #1b1238;
--text-secondary: #5f5380;
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: rgba(20, 10, 60, 0.1);
--glass-shadow: 0 8px 32px rgba(57, 120, 255, 0.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text-primary);
background: var(--background-gradient);
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--primary-color); text-decoration: none; }
a:hover { color: var(--highlight-color); }
.container {
width: min(1200px, calc(100vw - 2rem));
margin: 0 auto;
padding: 0 var(--space-3);
}
.site-header {
position: sticky;
top: 0;
z-index: 200;
backdrop-filter: blur(16px);
background: rgba(13, 8, 33, 0.72);
border-bottom: 1px solid var(--glass-border);
}
html[data-theme="light"] .site-header { background: rgba(244, 240, 255, 0.88); }
.header-inner {
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-2) 0;
}
.brand-link {
display: flex;
align-items: center;
gap: 14px;
color: var(--text-primary);
text-decoration: none;
}
.brand-logo {
width: 46px;
height: 46px;
border-radius: 14px;
display: grid;
place-items: center;
font-family: "Russo One", sans-serif;
font-size: 22px;
background: linear-gradient(135deg, var(--primary-color), var(--accent-color-2));
box-shadow: 0 10px 30px rgba(255, 81, 249, 0.28);
}
.brand-title {
font-family: "Russo One", sans-serif;
font-size: 1.35rem;
letter-spacing: 0.04em;
line-height: 1.1;
}
.brand-sub {
color: var(--text-secondary);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 700;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.status-pill {
padding: 0.55rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
font-size: 0.82rem;
color: var(--text-secondary);
}
.status-pill.bad { border-color: rgba(255, 77, 109, 0.45); color: #ffb4c2; }
.app-main { padding: var(--space-4) 0 var(--space-5); }
.hero-copy { margin-bottom: var(--space-3); }
.hero-copy h1 {
font-family: "Russo One", sans-serif;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.1;
margin: 0.4rem 0 0.8rem;
}
.hero-copy p { color: var(--text-secondary); max-width: 760px; }
.glass-card,
.panel {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2xl);
backdrop-filter: blur(10px);
box-shadow: var(--glass-shadow);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
.section-head {
display: flex;
justify-content: space-between;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
flex-wrap: wrap;
}
h2 { font-size: 1.25rem; margin-bottom: 0.75rem; }
h3 { font-size: 1rem; margin-bottom: 0.5rem; }
.muted, .text-secondary { color: var(--text-secondary); }
.no-margin { margin: 0; }
.block { display: block; margin-top: 0.5rem; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 0.72rem 1rem;
font-weight: 700;
cursor: pointer;
font-size: 0.92rem;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.btn:hover { transform: translateY(-1px); }
.btn:disabled { opacity: 0.6; cursor: wait; transform: none; }
.btn-primary {
color: var(--text-primary);
background: linear-gradient(135deg, var(--primary-color), var(--accent-color-1));
}
.btn-secondary {
color: var(--text-primary);
background: var(--glass-bg);
border-color: var(--glass-border);
}
.btn-ghost {
color: var(--text-secondary);
background: transparent;
border-color: var(--glass-border);
}
.icon-btn { min-width: 44px; padding-inline: 0.7rem; }
.full-width { width: 100%; }
label {
display: block;
color: var(--text-secondary);
font-size: 0.82rem;
margin-bottom: 0.45rem;
font-weight: 600;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
background: rgba(0, 0, 0, 0.22);
color: var(--text-primary);
padding: 0.72rem 0.85rem;
font-size: 0.95rem;
}
html[data-theme="light"] input,
html[data-theme="light"] select,
html[data-theme="light"] textarea { background: rgba(255,255,255,0.92); }
.controls {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1fr auto;
gap: 12px;
align-items: end;
}
.search-wrap { position: relative; }
.search-results {
position: absolute;
z-index: 50;
left: 0; right: 0;
top: calc(100% + 6px);
list-style: none;
border-radius: var(--radius-lg);
background: rgba(20, 12, 40, 0.96);
border: 1px solid var(--glass-border);
max-height: 280px;
overflow: auto;
box-shadow: var(--glass-shadow);
}
html[data-theme="light"] .search-results { background: #fff; }
.search-results.hidden { display: none; }
.search-results li { padding: 0.7rem 0.85rem; cursor: pointer; }
.search-results li:hover { background: rgba(255,255,255,0.06); }
.search-results li strong { display: block; }
.search-results li span { color: var(--text-secondary); font-size: 0.78rem; }
.control-actions { display: flex; gap: 8px; align-items: end; }
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: var(--space-3);
}
.tab {
border-radius: var(--radius-lg);
padding: 0.62rem 0.95rem;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
font-weight: 600;
}
.tab.active {
background: linear-gradient(135deg, rgba(255,81,249,0.95), rgba(57,120,255,0.95));
border-color: transparent;
}
.view { display: none; }
.view.active { display: block; }
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
}
.card { min-height: 140px; }
.card h2 { font-size: 0.95rem; color: var(--text-secondary); font-weight: 600; }
.big {
font-size: clamp(1.8rem, 4vw, 2.4rem);
font-weight: 800;
line-height: 1.1;
margin: 0.35rem 0;
}
.metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.metric, .day, .hour-card, .source-item, .source-status, .warning-card, .admin-stat {
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 14px;
background: rgba(0,0,0,0.14);
}
.metric span { display: block; color: var(--text-secondary); font-size: 0.78rem; margin-bottom: 6px; }
.metric strong { font-size: 1.35rem; }
.hour-strip, .days {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.hour-card span { display: block; font-size: 1.35rem; font-weight: 800; margin: 0.4rem 0; }
.bar { height: 6px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,0.12); margin: 0.7rem 0; }
.bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color)); }
#map {
min-height: 520px;
height: min(68vh, 720px);
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
overflow: hidden;
}
.map-actions, .timeline-meta { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
.timeline-controls {
display: grid;
grid-template-columns: repeat(5, auto) 1fr;
gap: 8px;
align-items: center;
}
.panel-inner {
margin-top: 12px;
padding: 14px;
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
background: rgba(0,0,0,0.12);
}
.inline-check { display: inline-flex; gap: 8px; align-items: center; font-size: 0.85rem; color: var(--text-secondary); }
.inline-check input { width: auto; }
.warnings-list { display: grid; gap: 14px; }
.warning-card { border-left: 4px solid var(--warn-info); }
.warning-card.warn-minor { border-left-color: var(--warn-minor); }
.warning-card.warn-moderate { border-left-color: var(--warn-moderate); }
.warning-card.warn-severe { border-left-color: var(--warn-severe); }
.warning-card.warn-extreme { border-left-color: var(--warn-extreme); }
.warning-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.warn-badge {
font-size: 0.75rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(255,255,255,0.08);
}
.instruction {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--glass-border);
color: var(--text-secondary);
font-size: 0.9rem;
}
.warncell-info {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: var(--radius-lg);
border: 1px solid var(--glass-border);
background: rgba(57,120,255,0.08);
font-size: 0.85rem;
}
.source-status-grid, .admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.source-status.ok { border-color: rgba(50, 251, 162, 0.35); }
.source-status.error { border-color: rgba(255, 77, 109, 0.4); }
.source-status strong { display: block; text-transform: capitalize; }
.chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
border-radius: 999px;
padding: 0.45rem 0.85rem;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
}
.favorites-panel.hidden { display: none; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--glass-border); }
th { color: var(--text-secondary); font-size: 0.8rem; }
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.rendered-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.rendered-frame { border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.rendered-frame img { width: 100%; display: block; aspect-ratio: 4/3; object-fit: cover; }
.rendered-frame figcaption { padding: 8px 10px; font-size: 0.78rem; color: var(--text-secondary); }
.site-footer {
border-top: 1px solid var(--glass-border);
background: rgba(0,0,0,0.22);
padding: var(--space-4) 0;
color: var(--text-secondary);
font-size: 0.86rem;
}
.footer-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: var(--space-3);
}
.footer-grid ul { list-style: none; }
.footer-grid li { margin-bottom: 0.35rem; }
.footer-grid a { color: var(--text-secondary); }
.footer-grid a:hover { color: var(--primary-color); }
.leaflet-control-layers, .leaflet-popup-content-wrapper { color: #1b1238; }
/* Admin */
.admin-page { min-height: 100vh; }
.admin-login-wrap {
min-height: calc(100vh - 80px);
display: grid;
place-items: center;
padding: var(--space-4) 0;
}
.admin-login-card { width: min(420px, 100%); }
.admin-login-card h1 {
font-family: "Russo One", sans-serif;
font-size: 1.8rem;
margin-bottom: 0.5rem;
}
.admin-error {
color: var(--error-color);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.admin-hidden { display: none !important; }
.hidden { display: none !important; }
.admin-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.admin-stat strong { display: block; font-size: 1.2rem; }
.admin-stat span { color: var(--text-secondary); font-size: 0.82rem; }
.config-block {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
}
.legal-page { max-width: 760px; margin: 40px auto; padding: 0 20px; }
.legal-page h1 { font-family: "Russo One", sans-serif; }
@media (max-width: 1000px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.controls { grid-template-columns: 1fr 1fr; }
.search-wrap, .control-actions { grid-column: 1 / -1; }
.footer-grid { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.grid, .metrics, .controls { grid-template-columns: 1fr; }
#map { min-height: 380px; height: 52vh; }
.timeline-controls { grid-template-columns: repeat(5, auto); }
.timeline-controls input[type=range] { grid-column: 1 / -1; }
}

View File

@@ -0,0 +1,100 @@
# HexaHost Branding Guide
Dieser Guide basiert auf dem bestehenden Design aus `HexaHost-Frontend` und `HexaHost-Backend`.
Nutze ihn als Standard für neue Projekte, damit Optik, Struktur und Tonalität konsistent bleiben.
## 1) Markenidentität
- Positionierung: Zuverlässiges Hosting mit persönlichem Support aus Deutschland.
- Tonalität: Klar, direkt, professionell, vertrauensvoll.
- Bildsprache: Modern, technisch, leicht futuristisch (Glassmorphism + Neon-Akzente).
## 2) Farben
Primäre Farben:
- Hintergrund: `#0d0821`
- Primary: `#ff51f9`
- Accent Violet: `#a348ff`
- Accent Blue: `#3978ff`
- Highlight: `#f093ff`
Weitere Farben:
- Success: `#32fba2`
- Warning: `#ffcc00`
- Error: `#ff4d6d`
- Text Primary: `#ffffff`
- Text Secondary: `#cfc9dd`
Designprinzip:
- Dunkler Hintergrund + leuchtende Akzente.
- Gradient-Buttons für wichtige CTAs.
- Transparente Karten mit Blur für Tiefe.
## 3) Typografie
- Hauptschrift: `Inter` (Body, Navigation, UI)
- Logo/Brand: `Russo One` (nur sparsam nutzen)
- Sekundär: `Source Sans Pro` (optional für Slogans)
Empfohlene Größen:
- H1: `3rem`
- H2: `2.25rem`
- H3: `1.5rem`
- Body: `1rem`
- Small: `0.875rem`
## 4) Layout und Spacing
- Container-Breite: `1200px`
- Standard-Abstand horizontal: `1.5rem`
- Sektionen: `3rem` bis `4rem` vertikales Padding
- Radius: `0.75rem` bis `1.5rem`
## 5) Komponenten
- Header: Fixiert mit Blur-Hintergrund.
- Buttons:
- Primary: Pink/Violett-Gradient
- Secondary: Glas-Look mit Border
- Cards: `.glass-card` mit Blur, Border und Shadow.
- Footer: 3-4 Spalten mit Produkt-, Firmen- und Support-Links.
## 6) Inhaltsstruktur (Seiten)
Jede neue Marketing-Seite sollte diese Reihenfolge nutzen:
1. Hero (Claim + CTA)
2. Leistungs- oder Produktsektion
3. Vorteilsektion
4. Vertrauens-/Support-Sektion
5. Abschließende CTA
6. Footer
## 7) Text- und CTA-Regeln
- CTAs immer aktiv formulieren:
- "Jetzt starten"
- "Produkte entdecken"
- "Kontakt aufnehmen"
- Pro Abschnitt ein klares Ziel.
- Kurze, konkrete Nutzenargumente statt technischer Überladung.
## 8) Technische Standards
- Wiederverwendbare Includes für Header/Footer nutzen.
- Styles zentral in `assets/css/style.css`.
- JS nur für Interaktion (Navigation, Accordions, Form-Hinweise).
- Performance: Preconnect für Fonts/CDN und CSS früh laden.
## 9) Template-Nutzung
Verwende den Ordner `HexaHost-Template-Website` als Startpunkt:
- `public/index.php` als Basisseite
- `includes/header.php` und `includes/footer.php` für einheitliches Layout
- `assets/css/style.css` für Designsystem und Komponenten

View File

@@ -0,0 +1,19 @@
# HexaHost Template Website
Kleine Template-Website als Grundlage fuer neue Webprojekte.
## Inhalt
- Basisstruktur fuer eine einfache Website
- Oeffentliche Dateien im `public`-Bereich
- Leicht anpassbar fuer eigene Inhalte
## Verwendung
1. Template-Ordner kopieren
2. Inhalte, Texte und Styles anpassen
3. Auf den Webserver hochladen
## Hinweis
Diese Vorlage ist bewusst schlank gehalten und kann flexibel erweitert werden.

View File

@@ -0,0 +1,198 @@
:root {
--background-color: #0d0821;
--primary-color: #ff51f9;
--accent-color-1: #a348ff;
--accent-color-2: #3978ff;
--highlight-color: #f093ff;
--text-primary: #ffffff;
--text-secondary: #cfc9dd;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.12);
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 3rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text-primary);
background: linear-gradient(135deg, #0d0821 0%, #1a1230 100%);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-3);
}
.header {
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(16px);
background: rgba(13, 8, 33, 0.6);
border-bottom: 1px solid var(--glass-border);
}
.nav-container {
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
font-family: "Russo One", sans-serif;
color: var(--text-primary);
text-decoration: none;
letter-spacing: 0.04em;
}
.nav-menu {
list-style: none;
display: flex;
gap: var(--space-2);
}
.nav-link {
color: var(--text-primary);
text-decoration: none;
padding: 0.45rem 0.7rem;
border-radius: 0.5rem;
}
.nav-link:hover,
.nav-link.active {
background: var(--glass-bg);
}
.hero {
padding: var(--space-5) 0;
}
.hero-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: var(--space-4);
align-items: center;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.2;
margin-bottom: var(--space-2);
}
h2 {
font-size: clamp(1.6rem, 3vw, 2.2rem);
margin-bottom: var(--space-3);
}
h3 {
margin-bottom: var(--space-1);
}
p {
color: var(--text-secondary);
}
.hero-actions {
margin-top: var(--space-3);
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
border-radius: var(--radius-lg);
padding: 0.75rem 1.1rem;
font-weight: 600;
border: 1px solid transparent;
}
.btn-primary {
color: var(--text-primary);
background: linear-gradient(135deg, var(--primary-color), var(--accent-color-1));
}
.btn-secondary {
color: var(--text-primary);
background: var(--glass-bg);
border-color: var(--glass-border);
}
.glass-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(255, 81, 249, 0.15);
}
.info-card {
padding: var(--space-4);
}
.section {
padding: var(--space-5) 0;
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.card {
padding: var(--space-3);
}
.cta-box {
text-align: center;
padding: var(--space-4);
}
.footer {
border-top: 1px solid var(--glass-border);
background: rgba(0, 0, 0, 0.25);
padding: var(--space-4) 0;
}
.footer-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.footer ul {
list-style: none;
}
.footer a {
color: var(--text-secondary);
text-decoration: none;
}
@media (max-width: 900px) {
.hero-grid,
.cards,
.footer-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,20 @@
document.addEventListener("DOMContentLoaded", () => {
const links = document.querySelectorAll('a[href^="#"]');
links.forEach((link) => {
link.addEventListener("click", (event) => {
const targetId = link.getAttribute("href");
if (!targetId || targetId === "#") {
return;
}
const target = document.querySelector(targetId);
if (!target) {
return;
}
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
});

View File

@@ -0,0 +1,29 @@
<footer class="footer" id="kontakt">
<div class="container footer-grid">
<div>
<h4>HexaHost</h4>
<p>Zuverlässiges Hosting aus Deutschland.</p>
</div>
<div>
<h4>Produkte</h4>
<ul>
<li><a href="#">VPS</a></li>
<li><a href="#">VPC</a></li>
<li><a href="#">Webhosting</a></li>
</ul>
</div>
<div>
<h4>Kontakt</h4>
<ul>
<li><a href="mailto:info@example.de">info@example.de</a></li>
<li><a href="#">Impressum</a></li>
<li><a href="#">Datenschutz</a></li>
</ul>
</div>
</div>
</footer>
<script src="../assets/js/main.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
function includeHeader(string $pageTitle, string $pageDescription, string $currentPage = 'home'): void
{
include __DIR__ . '/header.php';
}
function includeFooter(): void
{
include __DIR__ . '/footer.php';
}

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8'); ?></title>
<meta name="description" content="<?php echo htmlspecialchars($pageDescription, ENT_QUOTES, 'UTF-8'); ?>">
<meta name="theme-color" content="#0d0821">
<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=Inter:wght@400;500;600;700&family=Russo+One&family=Source+Sans+Pro:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header class="header">
<nav class="nav">
<div class="container nav-container">
<a href="./index.php" class="brand">HexaHost</a>
<ul class="nav-menu">
<li><a class="nav-link <?php echo $currentPage === 'home' ? 'active' : ''; ?>" href="./index.php">Home</a></li>
<li><a class="nav-link" href="#leistungen">Leistungen</a></li>
<li><a class="nav-link" href="#kontakt">Kontakt</a></li>
</ul>
</div>
</nav>
</header>

View File

@@ -0,0 +1,62 @@
<?php
require_once __DIR__ . '/../includes/functions.php';
$pageTitle = 'HexaHost Template - Einheitliches Webdesign';
$pageDescription = 'Template-Seite mit einheitlichem HexaHost Branding.';
$currentPage = 'home';
includeHeader($pageTitle, $pageDescription, $currentPage);
?>
<main>
<section class="hero">
<div class="container hero-grid">
<div>
<h1>Einheitliches Webdesign für alle neuen Projekte</h1>
<p>
Diese Vorlage nutzt dieselbe visuelle Sprache wie Frontend und Backend:
klare Typografie, Neon-Akzente und konsistente CTA-Führung.
</p>
<div class="hero-actions">
<a href="#leistungen" class="btn btn-primary">Template nutzen</a>
<a href="#kontakt" class="btn btn-secondary">Kontakt aufnehmen</a>
</div>
</div>
<aside class="glass-card info-card">
<h3>Designsystem aktiv</h3>
<p>Farben, Typografie und Komponenten sind zentral standardisiert.</p>
</aside>
</div>
</section>
<section id="leistungen" class="section">
<div class="container">
<h2>Bausteine der Vorlage</h2>
<div class="cards">
<article class="glass-card card">
<h3>Header + Footer</h3>
<p>Wiederverwendbare Includes für konsistente Navigation und Abschluss.</p>
</article>
<article class="glass-card card">
<h3>Komponenten</h3>
<p>Buttons, Karten und Sektionen folgen festen Designregeln.</p>
</article>
<article class="glass-card card">
<h3>Branding Guide</h3>
<p>Im Guide sind Farbwerte, Tonalität und Content-Regeln dokumentiert.</p>
</article>
</div>
</div>
</section>
<section class="section cta">
<div class="container glass-card cta-box">
<h2>Neue Website in Minuten starten</h2>
<p>Kopiere diesen Ordner, passe Inhalte an und behalte überall denselben Look.</p>
<a href="#" class="btn btn-primary">Projekt starten</a>
</div>
</section>
</main>
<?php includeFooter(); ?>

24
nginx.conf Normal file
View File

@@ -0,0 +1,24 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
error_page 502 503 504 /502.html;
location /api/ {
proxy_pass http://api:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
}
location / {
try_files $uri $uri/ /index.html;
}
}

17
renderer/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libhdf5-dev gcc g++ \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY renderer.py .
CMD ["python", "renderer.py"]

244
renderer/renderer.py Normal file
View File

@@ -0,0 +1,244 @@
import asyncio
import json
import logging
import os
import re
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import asyncpg
import h5py
import numpy as np
from PIL import Image
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("radar-renderer")
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
RENDERED_RADAR_DIR = DATA_DIR / "processed" / "radar"
RADAR_PRODUCT = os.getenv("RADAR_PRODUCT", "rv")
RENDER_INTERVAL_SECONDS = int(os.getenv("RADAR_RENDER_INTERVAL_SECONDS", "300"))
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://hexawetter:hexawetter@postgres:5432/hexawetter")
RADAR_FILE_RE = re.compile(r"^[A-Za-z0-9_.-]+\.(?:tar|tar\.bz2|bz2|gz)$", re.IGNORECASE)
# DWD RV composite extent (EPSG:4326 approximate)
DEFAULT_BOUNDS = {
"south": 47.0,
"west": 3.0,
"north": 56.0,
"east": 16.0,
}
# Precipitation colormap (mm/h)
COLOR_STOPS = [
(0.0, (0, 0, 0, 0)),
(0.1, (100, 180, 255, 80)),
(0.5, (0, 120, 255, 140)),
(1.0, (0, 200, 80, 170)),
(2.0, (255, 255, 0, 200)),
(5.0, (255, 140, 0, 220)),
(10.0, (255, 0, 0, 230)),
(20.0, (180, 0, 120, 240)),
(50.0, (120, 0, 80, 250)),
]
def apply_colormap(values: np.ndarray) -> np.ndarray:
rgba = np.zeros((*values.shape, 4), dtype=np.uint8)
mask = np.isfinite(values) & (values > 0)
if not np.any(mask):
return rgba
vmax = max(float(np.nanpercentile(values[mask], 99)), 1.0)
normalized = np.clip(values / vmax, 0, 1)
stops = COLOR_STOPS
for idx in range(len(stops) - 1):
low_val, low_color = stops[idx]
high_val, high_color = stops[idx + 1]
if high_val <= low_val:
continue
band = mask & (values >= low_val) & (values < high_val)
if not np.any(band):
continue
ratio = (values[band] - low_val) / (high_val - low_val)
for channel in range(4):
rgba[..., channel][band] = (
low_color[channel] + ratio * (high_color[channel] - low_color[channel])
).astype(np.uint8)
top_band = mask & (values >= stops[-1][0])
if np.any(top_band):
for channel in range(4):
rgba[..., channel][top_band] = stops[-1][1][channel]
return rgba
def read_hdf5_dataset(path: Path) -> np.ndarray | None:
try:
with h5py.File(path, "r") as handle:
if "dataset1/data1/data" in handle:
data = handle["dataset1/data1/data"][:]
what = handle["dataset1/what"].attrs
nodata = what.get("nodata", -9999)
data = np.where(data == nodata, np.nan, data)
return data.astype(np.float32)
for key in handle.keys():
if isinstance(handle[key], h5py.Group) and "data" in handle[key]:
subgroup = handle[key]
for subkey in subgroup.keys():
if "data" in subgroup[subkey]:
return np.array(subgroup[subkey]["data"], dtype=np.float32)
except Exception as exc:
logger.warning("HDF5 read failed for %s: %s", path.name, exc)
return None
def extract_archive(archive_path: Path, temp_dir: Path) -> list[Path]:
extracted: list[Path] = []
if archive_path.suffix == ".bz2" and archive_path.name.endswith(".tar.bz2"):
mode = "r:bz2"
elif archive_path.suffix == ".gz":
mode = "r:gz"
else:
mode = "r"
with tarfile.open(archive_path, mode) as tar:
tar.extractall(temp_dir, filter="data")
for path in temp_dir.rglob("*"):
if path.is_file():
extracted.append(path)
return extracted
def render_png(data: np.ndarray, output_path: Path) -> dict:
rgba = apply_colormap(data)
image = Image.fromarray(rgba, mode="RGBA")
image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
output_path.parent.mkdir(parents=True, exist_ok=True)
image.save(output_path, format="PNG", optimize=True)
return DEFAULT_BOUNDS
async def upsert_meta(
pool: asyncpg.Pool,
filename: str,
file_size: int,
modified_at: datetime,
render_status: str,
render_path: str | None = None,
render_bounds: dict | None = None,
render_error: str | None = None,
) -> None:
await pool.execute(
"""
INSERT INTO radar_file_meta (
product, filename, file_size, modified_at, render_status,
render_path, render_bounds, render_error, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, NOW())
ON CONFLICT (filename) DO UPDATE SET
file_size = EXCLUDED.file_size,
modified_at = EXCLUDED.modified_at,
render_status = EXCLUDED.render_status,
render_path = EXCLUDED.render_path,
render_bounds = EXCLUDED.render_bounds,
render_error = EXCLUDED.render_error,
updated_at = NOW()
""",
RADAR_PRODUCT,
filename,
file_size,
modified_at,
render_status,
render_path,
json.dumps(render_bounds) if render_bounds else None,
render_error,
)
async def process_file(pool: asyncpg.Pool, archive_path: Path) -> None:
filename = archive_path.name
modified_at = datetime.fromtimestamp(archive_path.stat().st_mtime, timezone.utc)
output_path = RENDERED_RADAR_DIR / RADAR_PRODUCT / f"{filename}.png"
if output_path.exists() and output_path.stat().st_size > 0:
await upsert_meta(
pool,
filename,
archive_path.stat().st_size,
modified_at,
"done",
str(output_path),
DEFAULT_BOUNDS,
)
return
await upsert_meta(pool, filename, archive_path.stat().st_size, modified_at, "processing")
try:
with tempfile.TemporaryDirectory() as temp_name:
temp_dir = Path(temp_name)
extracted = extract_archive(archive_path, temp_dir)
data = None
for candidate in extracted:
if candidate.suffix.lower() in {".h5", ".hdf5"} or "RV" in candidate.name.upper():
data = read_hdf5_dataset(candidate)
if data is not None:
break
if data is None:
for candidate in extracted:
data = read_hdf5_dataset(candidate)
if data is not None:
break
if data is None:
raise RuntimeError("No renderable HDF5 dataset found in archive")
bounds = render_png(data, output_path)
await upsert_meta(
pool,
filename,
archive_path.stat().st_size,
modified_at,
"done",
str(output_path),
bounds,
)
logger.info("rendered %s -> %s", filename, output_path)
except Exception as exc:
await upsert_meta(
pool,
filename,
archive_path.stat().st_size,
modified_at,
"error",
render_error=str(exc),
)
logger.error("render failed for %s: %s", filename, exc)
async def render_pending() -> None:
target_dir = RAW_RADAR_DIR / "composite" / RADAR_PRODUCT
target_dir.mkdir(parents=True, exist_ok=True)
RENDERED_RADAR_DIR.mkdir(parents=True, exist_ok=True)
pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=3)
try:
archives = sorted(
[p for p in target_dir.iterdir() if p.is_file() and RADAR_FILE_RE.fullmatch(p.name)],
key=lambda p: p.stat().st_mtime,
)[-12:]
for archive_path in archives:
await process_file(pool, archive_path)
finally:
await pool.close()
async def main() -> None:
logger.info("radar renderer started (product=%s)", RADAR_PRODUCT)
while True:
try:
await render_pending()
except Exception as exc:
logger.exception("render cycle failed: %s", exc)
await asyncio.sleep(RENDER_INTERVAL_SECONDS)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,4 @@
h5py==3.12.1
numpy==2.2.1
Pillow==11.0.0
asyncpg==0.30.0

Some files were not shown because too many files have changed in this diff Show More