initial commit
7
.cursor/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"redis-development": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
.env
Normal 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
@@ -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
@@ -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
@@ -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 (60–120 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
@@ -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
87
api/app/auth.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||||
|
}
|
||||||
0
api/app/routes/__init__.py
Normal file
154
api/app/routes/admin.py
Normal 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."}
|
||||||
0
api/app/services/__init__.py
Normal file
67
api/app/services/dashboard.py
Normal 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
|
||||||
43
api/app/services/dwd_radar.py
Normal 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}
|
||||||
114
api/app/services/geocoding.py
Normal 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
|
||||||
75
api/app/services/health.py
Normal 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¤t=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
|
||||||
93
api/app/services/warncells.py
Normal 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
|
||||||
128
api/app/services/warnings.py
Normal 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,
|
||||||
|
}
|
||||||
66
api/app/services/weather.py
Normal 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
@@ -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
@@ -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
|
||||||
BIN
data/processed/radar/rv/composite_rv_20260618_2025.tar.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2030.tar.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2035.tar.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2040.tar.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2045.tar.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2050.tar.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2055.tar.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2100.tar.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2105.tar.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2110.tar.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2115.tar.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2120.tar.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2125.tar.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
data/processed/radar/rv/composite_rv_20260618_2130.tar.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
data/processed/radar/rv/composite_rv_LATEST.tar.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
data/raw/radar/composite/rv/DE1200_RV2606181945.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606181950.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606181955.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182000.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182005.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182010.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182015.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182020.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182025.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182030.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182035.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV2606182040.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/DE1200_RV_LATEST.tar.bz2
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_1955.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2000.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2005.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2010.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2015.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2020.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2025.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2030.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2035.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2040.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2045.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2050.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2055.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2100.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2105.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2110.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2115.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2120.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2125.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2130.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_20260618_2135.tar
Normal file
BIN
data/raw/radar/composite/rv/composite_rv_LATEST.tar
Normal file
48
db/init.sql
Normal 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);
|
||||||
49
docker-compose.selfhosted.yml
Normal 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:
|
||||||
16
docker-compose.traefik.yml
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: "© 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
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
24
nginx.conf
Normal 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
@@ -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
@@ -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())
|
||||||
4
renderer/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
h5py==3.12.1
|
||||||
|
numpy==2.2.1
|
||||||
|
Pillow==11.0.0
|
||||||
|
asyncpg==0.30.0
|
||||||
38
scripts/hash_admin_password.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate bcrypt hash and JWT secret for HexaWetter admin login."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain_password: str) -> str:
|
||||||
|
return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def docker_compose_escape(value: str) -> str:
|
||||||
|
"""Escape $ for docker compose .env interpolation ($$ -> literal $)."""
|
||||||
|
return value.replace("$", "$$")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate HexaWetter admin credentials")
|
||||||
|
parser.add_argument("password", help="Admin password (min. 8 characters)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if len(args.password) < 8:
|
||||||
|
raise SystemExit("Password must be at least 8 characters")
|
||||||
|
|
||||||
|
password_hash = hash_password(args.password)
|
||||||
|
jwt_secret = secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
print("Add these lines to your .env file:\n")
|
||||||
|
print("ADMIN_USERNAME=admin")
|
||||||
|
print(f"ADMIN_PASSWORD_HASH={docker_compose_escape(password_hash)}")
|
||||||
|
print(f"ADMIN_JWT_SECRET={jwt_secret}")
|
||||||
|
print("\n# Hinweis: bcrypt-Hashes enthalten $. Fuer docker compose muss jedes $ als $$ escaped sein.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
16
worker/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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 worker.py .
|
||||||
|
|
||||||
|
CMD ["python", "worker.py"]
|
||||||
1
worker/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
httpx==0.28.1
|
||||||
83
worker/worker.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
|
||||||
|
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
|
||||||
|
DWD_RADAR_BASE_URL = os.getenv("DWD_RADAR_BASE_URL", "https://opendata.dwd.de/weather/radar/composite").rstrip("/")
|
||||||
|
RADAR_PRODUCT = os.getenv("RADAR_PRODUCT", "rv")
|
||||||
|
RADAR_SYNC_LIMIT = int(os.getenv("RADAR_SYNC_LIMIT", "12"))
|
||||||
|
RADAR_SYNC_INTERVAL_SECONDS = int(os.getenv("RADAR_SYNC_INTERVAL_SECONDS", "300"))
|
||||||
|
RADAR_RETENTION_HOURS = int(os.getenv("RADAR_RETENTION_HOURS", "48"))
|
||||||
|
RADAR_FILE_RE = re.compile(r"^[A-Za-z0-9_.-]+\.(?:tar|tar\.bz2|bz2|gz)$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
print(f"[{now_utc().isoformat()}] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_apache_index(html: str) -> list[str]:
|
||||||
|
pattern = re.compile(r'href="(?P<href>[^"]+\.(?:tar|tar\.bz2|bz2|gz))"', re.IGNORECASE)
|
||||||
|
return [m.group("href") for m in pattern.finditer(html) if not m.group("href").startswith("../")]
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup() -> None:
|
||||||
|
cutoff = now_utc() - 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)
|
||||||
|
log(f"deleted old radar file {path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_once() -> None:
|
||||||
|
base_url = f"{DWD_RADAR_BASE_URL}/{RADAR_PRODUCT}/"
|
||||||
|
target_dir = RAW_RADAR_DIR / "composite" / RADAR_PRODUCT
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=90.0, follow_redirects=True) as client:
|
||||||
|
response = await client.get(base_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
files = parse_apache_index(response.text)[-RADAR_SYNC_LIMIT:]
|
||||||
|
if not files:
|
||||||
|
log(f"no radar files found at {base_url}")
|
||||||
|
return
|
||||||
|
downloaded = 0
|
||||||
|
for filename in files:
|
||||||
|
target = target_dir / filename
|
||||||
|
if target.exists() and target.stat().st_size > 0:
|
||||||
|
continue
|
||||||
|
file_response = await client.get(urljoin(base_url, filename))
|
||||||
|
file_response.raise_for_status()
|
||||||
|
target.write_bytes(file_response.content)
|
||||||
|
downloaded += 1
|
||||||
|
log(f"downloaded {filename}")
|
||||||
|
cleanup()
|
||||||
|
log(f"sync complete: product={RADAR_PRODUCT}, checked={len(files)}, downloaded={downloaded}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
log("radar worker started")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await sync_once()
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"sync failed: {exc}")
|
||||||
|
await asyncio.sleep(RADAR_SYNC_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||