Spaces:
Runtime error
Runtime error
Upload 37 files
Browse files- .env.example +4 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +1 -0
- .pytest_cache/v/cache/nodeids +6 -0
- Dockerfile +41 -0
- app/__init__.py +1 -0
- app/__pycache__/__init__.cpython-313.pyc +0 -0
- app/__pycache__/cache.cpython-313.pyc +0 -0
- app/__pycache__/config.cpython-313.pyc +0 -0
- app/__pycache__/fetcher.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/__pycache__/metrics.cpython-313.pyc +0 -0
- app/__pycache__/models.cpython-313.pyc +0 -0
- app/cache.py +72 -0
- app/config.py +37 -0
- app/fetcher.py +283 -0
- app/main.py +239 -0
- app/metrics.py +44 -0
- app/models.py +105 -0
- app/static/app.js +256 -0
- app/static/styles.css +331 -0
- app/templates/index.html.j2 +106 -0
- data/.gitkeep +1 -0
- data/cache.json +290 -0
- docker-compose.yml +15 -0
- pyproject.toml +63 -0
- rheinpegel_app.egg-info/PKG-INFO +139 -0
- rheinpegel_app.egg-info/SOURCES.txt +19 -0
- rheinpegel_app.egg-info/dependency_links.txt +1 -0
- rheinpegel_app.egg-info/requires.txt +17 -0
- rheinpegel_app.egg-info/top_level.txt +1 -0
- tests/__pycache__/test_endpoints.cpython-313-pytest-8.4.1.pyc +0 -0
- tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc +0 -0
- tests/test_endpoints.py +77 -0
- tests/test_parser.py +58 -0
.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SOURCE_URL=https://www.stadt-koeln.de/interne-dienste/hochwasser/pegel_ws.php
|
| 2 |
+
REFRESH_SECONDS=120
|
| 3 |
+
TZ=Europe/Berlin
|
| 4 |
+
PORT=8000
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"tests/test_endpoints.py::test_api_history_returns_entries",
|
| 3 |
+
"tests/test_endpoints.py::test_api_latest_returns_measurement",
|
| 4 |
+
"tests/test_parser.py::test_parse_json_payload",
|
| 5 |
+
"tests/test_parser.py::test_parse_xml_payload"
|
| 6 |
+
]
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
RUN apt-get update \
|
| 9 |
+
&& apt-get install -y --no-install-recommends curl \
|
| 10 |
+
&& apt-get clean \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
COPY pyproject.toml ./
|
| 14 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 15 |
+
&& pip install --no-cache-dir \
|
| 16 |
+
fastapi>=0.111 \
|
| 17 |
+
"uvicorn[standard]>=0.29" \
|
| 18 |
+
httpx>=0.27 \
|
| 19 |
+
python-dotenv>=1.0 \
|
| 20 |
+
jinja2>=3.1 \
|
| 21 |
+
pydantic>=2.7 \
|
| 22 |
+
pydantic-settings>=2.4 \
|
| 23 |
+
prometheus-client>=0.20 \
|
| 24 |
+
python-dateutil>=2.9 \
|
| 25 |
+
defusedxml>=0.7
|
| 26 |
+
|
| 27 |
+
COPY app ./app
|
| 28 |
+
COPY data ./data
|
| 29 |
+
COPY README.md ./
|
| 30 |
+
COPY .env.example ./
|
| 31 |
+
|
| 32 |
+
RUN adduser --system --group appuser \
|
| 33 |
+
&& chown -R appuser:appuser /app
|
| 34 |
+
|
| 35 |
+
USER appuser
|
| 36 |
+
|
| 37 |
+
EXPOSE 8000
|
| 38 |
+
|
| 39 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD curl -fsS http://localhost:8000/healthz || exit 1
|
| 40 |
+
|
| 41 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (174 Bytes). View file
|
|
|
app/__pycache__/cache.cpython-313.pyc
ADDED
|
Binary file (5.75 kB). View file
|
|
|
app/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (1.66 kB). View file
|
|
|
app/__pycache__/fetcher.cpython-313.pyc
ADDED
|
Binary file (12.8 kB). View file
|
|
|
app/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (14.3 kB). View file
|
|
|
app/__pycache__/metrics.cpython-313.pyc
ADDED
|
Binary file (2.13 kB). View file
|
|
|
app/__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (5.14 kB). View file
|
|
|
app/cache.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from collections import deque
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Deque
|
| 9 |
+
|
| 10 |
+
from .models import Measurement
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class MeasurementCache:
|
| 16 |
+
def __init__(self, *, maxlen: int = 48, storage_path: Path | str = "data/cache.json") -> None:
|
| 17 |
+
self._buffer: Deque[Measurement] = deque(maxlen=maxlen)
|
| 18 |
+
self._lock = asyncio.Lock()
|
| 19 |
+
self._path = Path(storage_path)
|
| 20 |
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
self._load_from_disk()
|
| 22 |
+
|
| 23 |
+
def _load_from_disk(self) -> None:
|
| 24 |
+
if not self._path.exists():
|
| 25 |
+
return
|
| 26 |
+
try:
|
| 27 |
+
raw = json.loads(self._path.read_text("utf-8"))
|
| 28 |
+
except json.JSONDecodeError as exc:
|
| 29 |
+
logger.warning("Failed to read cache file %s: %s", self._path, exc)
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
if not isinstance(raw, list):
|
| 33 |
+
logger.warning("Invalid cache file format: expected list, got %s", type(raw))
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
for item in raw[-self._buffer.maxlen :]:
|
| 37 |
+
try:
|
| 38 |
+
measurement = Measurement.model_validate(item)
|
| 39 |
+
except Exception as exc: # noqa: BLE001
|
| 40 |
+
logger.debug("Skipping invalid cache entry %s: %s", item, exc)
|
| 41 |
+
continue
|
| 42 |
+
self._buffer.append(measurement)
|
| 43 |
+
|
| 44 |
+
logger.info("Loaded %s cached measurements", len(self._buffer))
|
| 45 |
+
|
| 46 |
+
def _dump_to_disk(self) -> None:
|
| 47 |
+
data = [m.model_dump(mode="json") for m in self._buffer]
|
| 48 |
+
tmp_path = self._path.with_suffix(".tmp")
|
| 49 |
+
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 50 |
+
tmp_path.replace(self._path)
|
| 51 |
+
|
| 52 |
+
async def add(self, measurement: Measurement, *, persist: bool = True) -> None:
|
| 53 |
+
async with self._lock:
|
| 54 |
+
latest = self._buffer[-1] if self._buffer else None
|
| 55 |
+
if latest and latest.timestamp == measurement.timestamp:
|
| 56 |
+
self._buffer.pop()
|
| 57 |
+
self._buffer.append(measurement)
|
| 58 |
+
|
| 59 |
+
if persist:
|
| 60 |
+
await asyncio.to_thread(self._dump_to_disk)
|
| 61 |
+
|
| 62 |
+
async def get_latest(self) -> Measurement | None:
|
| 63 |
+
async with self._lock:
|
| 64 |
+
return self._buffer[-1] if self._buffer else None
|
| 65 |
+
|
| 66 |
+
async def get_history(self) -> list[Measurement]:
|
| 67 |
+
async with self._lock:
|
| 68 |
+
return list(self._buffer)
|
| 69 |
+
|
| 70 |
+
def __len__(self) -> int:
|
| 71 |
+
return len(self._buffer)
|
| 72 |
+
|
app/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from zoneinfo import ZoneInfo
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from pydantic import AnyHttpUrl
|
| 6 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
source_url: AnyHttpUrl = (
|
| 13 |
+
"https://www.stadt-koeln.de/interne-dienste/hochwasser/pegel_ws.php"
|
| 14 |
+
)
|
| 15 |
+
refresh_seconds: int = 120
|
| 16 |
+
tz: str = "Europe/Berlin"
|
| 17 |
+
port: int = 8000
|
| 18 |
+
|
| 19 |
+
model_config = SettingsConfigDict(
|
| 20 |
+
env_file=".env",
|
| 21 |
+
env_file_encoding="utf-8",
|
| 22 |
+
extra="ignore",
|
| 23 |
+
env_prefix="",
|
| 24 |
+
case_sensitive=False,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def timezone(self) -> ZoneInfo:
|
| 29 |
+
try:
|
| 30 |
+
return ZoneInfo(self.tz)
|
| 31 |
+
except Exception:
|
| 32 |
+
return ZoneInfo("Europe/Berlin")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@lru_cache
|
| 36 |
+
def get_settings() -> Settings:
|
| 37 |
+
return Settings()
|
app/fetcher.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
import random
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import httpx
|
| 12 |
+
from dateutil import parser as date_parser
|
| 13 |
+
from defusedxml import ElementTree as ET
|
| 14 |
+
|
| 15 |
+
from .config import Settings
|
| 16 |
+
from .models import Measurement, resolve_trend
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
MAX_ATTEMPTS = 4
|
| 21 |
+
BASE_BACKOFF = 0.5
|
| 22 |
+
TIMEOUT_SECONDS = 8.0
|
| 23 |
+
|
| 24 |
+
LEVEL_KEYS = ("level", "levelcm", "pegel", "wasserstand", "value", "stand")
|
| 25 |
+
TIME_KEYS = (
|
| 26 |
+
"timestamp",
|
| 27 |
+
"zeit",
|
| 28 |
+
"time",
|
| 29 |
+
"datetime",
|
| 30 |
+
"datum",
|
| 31 |
+
"messzeit",
|
| 32 |
+
"uhrzeit",
|
| 33 |
+
)
|
| 34 |
+
TREND_KEYS = ("trend", "tendency", "richtung")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(slots=True)
|
| 38 |
+
class FetchResult:
|
| 39 |
+
measurement: Measurement
|
| 40 |
+
latency_ms: float | None
|
| 41 |
+
is_demo: bool
|
| 42 |
+
error: str | None = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class FetchError(Exception):
|
| 46 |
+
"""Raised when fetching from the remote source fails."""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def fetch_latest(
|
| 50 |
+
settings: Settings,
|
| 51 |
+
previous: Measurement | None,
|
| 52 |
+
*,
|
| 53 |
+
force_demo: bool = False,
|
| 54 |
+
) -> FetchResult:
|
| 55 |
+
if force_demo:
|
| 56 |
+
measurement = _generate_demo(previous, settings)
|
| 57 |
+
return FetchResult(measurement=measurement, latency_ms=None, is_demo=True)
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
measurement, latency = await _fetch_from_source(settings, previous)
|
| 61 |
+
return FetchResult(measurement=measurement, latency_ms=latency, is_demo=False)
|
| 62 |
+
except Exception as exc: # noqa: BLE001
|
| 63 |
+
logger.warning("Falling back to demo mode due to: %s", exc)
|
| 64 |
+
measurement = _generate_demo(previous, settings)
|
| 65 |
+
return FetchResult(
|
| 66 |
+
measurement=measurement,
|
| 67 |
+
latency_ms=None,
|
| 68 |
+
is_demo=True,
|
| 69 |
+
error=str(exc),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def _fetch_from_source(
|
| 74 |
+
settings: Settings,
|
| 75 |
+
previous: Measurement | None,
|
| 76 |
+
) -> tuple[Measurement, float]:
|
| 77 |
+
url = str(settings.source_url)
|
| 78 |
+
timeout = httpx.Timeout(TIMEOUT_SECONDS)
|
| 79 |
+
|
| 80 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 81 |
+
last_error: Exception | None = None
|
| 82 |
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
| 83 |
+
start = time.perf_counter()
|
| 84 |
+
try:
|
| 85 |
+
response = await client.get(url)
|
| 86 |
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
| 87 |
+
response.raise_for_status()
|
| 88 |
+
measurement = _parse_response(response, settings, previous)
|
| 89 |
+
return measurement, elapsed_ms
|
| 90 |
+
except Exception as exc: # noqa: BLE001
|
| 91 |
+
last_error = exc
|
| 92 |
+
delay = BASE_BACKOFF * 2 ** (attempt - 1)
|
| 93 |
+
logger.debug(
|
| 94 |
+
"Fetch attempt %s failed (%s); retrying in %.2fs",
|
| 95 |
+
attempt,
|
| 96 |
+
exc,
|
| 97 |
+
delay,
|
| 98 |
+
)
|
| 99 |
+
if attempt == MAX_ATTEMPTS:
|
| 100 |
+
break
|
| 101 |
+
await asyncio.sleep(delay)
|
| 102 |
+
|
| 103 |
+
raise FetchError(f"Failed to fetch data from {url}") from last_error
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _parse_response(
|
| 107 |
+
response: httpx.Response,
|
| 108 |
+
settings: Settings,
|
| 109 |
+
previous: Measurement | None,
|
| 110 |
+
) -> Measurement:
|
| 111 |
+
content_type = response.headers.get("content-type", "").lower()
|
| 112 |
+
|
| 113 |
+
if "json" in content_type:
|
| 114 |
+
payload = response.json()
|
| 115 |
+
return _build_measurement_from_payload(payload, settings, previous)
|
| 116 |
+
|
| 117 |
+
text = response.text
|
| 118 |
+
# Try JSON first regardless of header to be robust
|
| 119 |
+
try:
|
| 120 |
+
payload = response.json()
|
| 121 |
+
return _build_measurement_from_payload(payload, settings, previous)
|
| 122 |
+
except Exception: # noqa: BLE001
|
| 123 |
+
pass
|
| 124 |
+
|
| 125 |
+
return _build_measurement_from_xml(text, settings, previous)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _build_measurement_from_payload(
|
| 129 |
+
payload: Any,
|
| 130 |
+
settings: Settings,
|
| 131 |
+
previous: Measurement | None,
|
| 132 |
+
) -> Measurement:
|
| 133 |
+
record = _select_record(payload)
|
| 134 |
+
if record is None:
|
| 135 |
+
raise ValueError("No valid record found in payload")
|
| 136 |
+
|
| 137 |
+
normalized = {str(k).lower(): v for k, v in record.items()}
|
| 138 |
+
level_cm = _extract_level(normalized)
|
| 139 |
+
timestamp = _extract_timestamp(normalized, settings)
|
| 140 |
+
provided_trend = _extract_trend(normalized)
|
| 141 |
+
|
| 142 |
+
trend = resolve_trend(level_cm, provided_trend, previous)
|
| 143 |
+
return Measurement(level_cm=level_cm, timestamp=timestamp, trend=trend)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _build_measurement_from_xml(
|
| 147 |
+
text: str,
|
| 148 |
+
settings: Settings,
|
| 149 |
+
previous: Measurement | None,
|
| 150 |
+
) -> Measurement:
|
| 151 |
+
root = ET.fromstring(text)
|
| 152 |
+
|
| 153 |
+
flat: dict[str, Any] = {}
|
| 154 |
+
for element in root.iter():
|
| 155 |
+
if element.text and element.text.strip():
|
| 156 |
+
flat[element.tag.lower()] = element.text.strip()
|
| 157 |
+
for key, value in element.attrib.items():
|
| 158 |
+
flat[f"{element.tag.lower()}_{key.lower()}"] = value
|
| 159 |
+
|
| 160 |
+
if not flat:
|
| 161 |
+
raise ValueError("XML payload did not contain any usable data")
|
| 162 |
+
|
| 163 |
+
level_cm = _extract_level(flat)
|
| 164 |
+
timestamp = _extract_timestamp(flat, settings)
|
| 165 |
+
provided_trend = _extract_trend(flat)
|
| 166 |
+
trend = resolve_trend(level_cm, provided_trend, previous)
|
| 167 |
+
return Measurement(level_cm=level_cm, timestamp=timestamp, trend=trend)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _select_record(payload: Any) -> dict[str, Any] | None:
|
| 171 |
+
if isinstance(payload, dict):
|
| 172 |
+
if any(isinstance(v, (dict, list)) for v in payload.values()):
|
| 173 |
+
for key in ("latest", "current", "data", "value", "measurement", "item", "werte"):
|
| 174 |
+
if key in payload:
|
| 175 |
+
nested = _select_record(payload[key])
|
| 176 |
+
if nested:
|
| 177 |
+
return nested
|
| 178 |
+
return payload
|
| 179 |
+
|
| 180 |
+
if isinstance(payload, list):
|
| 181 |
+
for item in reversed(payload):
|
| 182 |
+
nested = _select_record(item)
|
| 183 |
+
if nested:
|
| 184 |
+
return nested
|
| 185 |
+
return None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _extract_level(data: dict[str, Any]) -> int:
|
| 189 |
+
for key in LEVEL_KEYS:
|
| 190 |
+
if key in data:
|
| 191 |
+
value = data[key]
|
| 192 |
+
numeric = _coerce_float(value)
|
| 193 |
+
if numeric is not None:
|
| 194 |
+
# Values below 20 are likely metres; convert to centimetres.
|
| 195 |
+
if numeric < 20:
|
| 196 |
+
numeric *= 100
|
| 197 |
+
return max(0, int(round(numeric)))
|
| 198 |
+
raise ValueError("Could not determine level value")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _extract_timestamp(data: dict[str, Any], settings: Settings) -> datetime:
|
| 202 |
+
tz = settings.timezone
|
| 203 |
+
|
| 204 |
+
# Handle separate date/time fields (e.g. Datum + Uhrzeit)
|
| 205 |
+
date_value = data.get("datum") or data.get("date")
|
| 206 |
+
time_value = (
|
| 207 |
+
data.get("uhrzeit")
|
| 208 |
+
or data.get("zeit")
|
| 209 |
+
or data.get("time")
|
| 210 |
+
or data.get("messzeit")
|
| 211 |
+
)
|
| 212 |
+
if date_value and time_value:
|
| 213 |
+
combined = f"{date_value} {time_value}"
|
| 214 |
+
parsed = _coerce_datetime(combined, tz)
|
| 215 |
+
if parsed is not None:
|
| 216 |
+
return parsed
|
| 217 |
+
|
| 218 |
+
for key in TIME_KEYS:
|
| 219 |
+
if key in data and data[key] is not None:
|
| 220 |
+
value = data[key]
|
| 221 |
+
parsed = _coerce_datetime(value, tz)
|
| 222 |
+
if parsed is not None:
|
| 223 |
+
return parsed
|
| 224 |
+
return datetime.now(tz)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _extract_trend(data: dict[str, Any]) -> int | None:
|
| 228 |
+
for key in TREND_KEYS:
|
| 229 |
+
if key in data:
|
| 230 |
+
raw = data[key]
|
| 231 |
+
if raw is None:
|
| 232 |
+
continue
|
| 233 |
+
if isinstance(raw, (int, float)) and raw in (-1, 0, 1):
|
| 234 |
+
return int(raw)
|
| 235 |
+
text = str(raw).strip().lower()
|
| 236 |
+
if text in {"-1", "falling", "down", "sinkend"}:
|
| 237 |
+
return -1
|
| 238 |
+
if text in {"1", "rising", "up", "steigend"}:
|
| 239 |
+
return 1
|
| 240 |
+
if text in {"0", "stable", "gleich", "steady"}:
|
| 241 |
+
return 0
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _coerce_float(value: Any) -> float | None:
|
| 246 |
+
if value is None:
|
| 247 |
+
return None
|
| 248 |
+
if isinstance(value, (int, float)):
|
| 249 |
+
return float(value)
|
| 250 |
+
text = str(value).strip().replace(",", ".")
|
| 251 |
+
try:
|
| 252 |
+
return float(text)
|
| 253 |
+
except ValueError:
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _coerce_datetime(value: Any, tz) -> datetime | None:
|
| 258 |
+
if value is None:
|
| 259 |
+
return None
|
| 260 |
+
if isinstance(value, (int, float)):
|
| 261 |
+
# Assume Unix timestamp (seconds)
|
| 262 |
+
return datetime.fromtimestamp(float(value), tz=tz)
|
| 263 |
+
text = str(value).strip()
|
| 264 |
+
if not text:
|
| 265 |
+
return None
|
| 266 |
+
try:
|
| 267 |
+
parsed = date_parser.parse(text)
|
| 268 |
+
except (ValueError, TypeError) as exc:
|
| 269 |
+
logger.debug("Failed to parse datetime %s: %s", value, exc)
|
| 270 |
+
return None
|
| 271 |
+
if parsed.tzinfo is None:
|
| 272 |
+
parsed = parsed.replace(tzinfo=tz)
|
| 273 |
+
return parsed.astimezone(tz)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _generate_demo(previous: Measurement | None, settings: Settings) -> Measurement:
|
| 277 |
+
base = previous.level_cm if previous else 380
|
| 278 |
+
jitter = random.randint(-10, 10)
|
| 279 |
+
drift = random.choice([-3, -2, -1, 0, 1, 2, 3])
|
| 280 |
+
level = max(250, min(900, base + drift + jitter))
|
| 281 |
+
timestamp = datetime.now(settings.timezone)
|
| 282 |
+
trend = resolve_trend(level, None, previous)
|
| 283 |
+
return Measurement(level_cm=level, timestamp=timestamp, trend=trend, is_demo=True)
|
app/main.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from contextlib import asynccontextmanager, suppress
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Annotated
|
| 9 |
+
|
| 10 |
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
| 11 |
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from fastapi.templating import Jinja2Templates
|
| 14 |
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
| 15 |
+
|
| 16 |
+
from .cache import MeasurementCache
|
| 17 |
+
from .config import Settings, get_settings
|
| 18 |
+
from .fetcher import FetchResult, fetch_latest
|
| 19 |
+
from .metrics import record_fetch, set_background_state, set_data_age
|
| 20 |
+
from .models import HistoryResponse, LatestResponse, Measurement
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 25 |
+
TEMPLATES = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
| 26 |
+
|
| 27 |
+
@asynccontextmanager
|
| 28 |
+
async def lifespan(app: FastAPI):
|
| 29 |
+
logging.basicConfig(level=logging.INFO)
|
| 30 |
+
await service.start()
|
| 31 |
+
try:
|
| 32 |
+
yield
|
| 33 |
+
finally:
|
| 34 |
+
await service.stop()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
app = FastAPI(title="Rheinpegel App", version="0.1.0", lifespan=lifespan)
|
| 38 |
+
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PegelService:
|
| 42 |
+
def __init__(self, settings: Settings, cache: MeasurementCache) -> None:
|
| 43 |
+
self.settings = settings
|
| 44 |
+
self.cache = cache
|
| 45 |
+
self._lock = asyncio.Lock()
|
| 46 |
+
self._task: asyncio.Task[None] | None = None
|
| 47 |
+
|
| 48 |
+
async def start(self) -> None:
|
| 49 |
+
if self._task and not self._task.done():
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
if len(self.cache) == 0:
|
| 53 |
+
try:
|
| 54 |
+
await self.fetch_and_update()
|
| 55 |
+
except Exception as exc: # noqa: BLE001
|
| 56 |
+
logger.warning("Initial fetch failed, cache remains empty: %s", exc)
|
| 57 |
+
|
| 58 |
+
self._task = asyncio.create_task(self._run_loop())
|
| 59 |
+
|
| 60 |
+
async def stop(self) -> None:
|
| 61 |
+
if self._task:
|
| 62 |
+
self._task.cancel()
|
| 63 |
+
with suppress(asyncio.CancelledError):
|
| 64 |
+
await self._task
|
| 65 |
+
self._task = None
|
| 66 |
+
set_background_state(False)
|
| 67 |
+
|
| 68 |
+
def is_running(self) -> bool:
|
| 69 |
+
return self._task is not None and not self._task.done()
|
| 70 |
+
|
| 71 |
+
async def fetch_and_update(
|
| 72 |
+
self,
|
| 73 |
+
*,
|
| 74 |
+
force_demo: bool = False,
|
| 75 |
+
) -> FetchResult:
|
| 76 |
+
async with self._lock:
|
| 77 |
+
previous = await self.cache.get_latest()
|
| 78 |
+
result = await fetch_latest(self.settings, previous, force_demo=force_demo)
|
| 79 |
+
await self.cache.add(result.measurement)
|
| 80 |
+
|
| 81 |
+
self._update_metrics(result)
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
async def latest(self) -> Measurement | None:
|
| 85 |
+
return await self.cache.get_latest()
|
| 86 |
+
|
| 87 |
+
async def history(self) -> list[Measurement]:
|
| 88 |
+
return await self.cache.get_history()
|
| 89 |
+
|
| 90 |
+
async def _run_loop(self) -> None:
|
| 91 |
+
set_background_state(True)
|
| 92 |
+
try:
|
| 93 |
+
while True:
|
| 94 |
+
try:
|
| 95 |
+
await self.fetch_and_update()
|
| 96 |
+
except Exception as exc: # noqa: BLE001
|
| 97 |
+
logger.exception("Background fetch failure: %s", exc)
|
| 98 |
+
await asyncio.sleep(self.settings.refresh_seconds)
|
| 99 |
+
except asyncio.CancelledError:
|
| 100 |
+
raise
|
| 101 |
+
finally:
|
| 102 |
+
set_background_state(False)
|
| 103 |
+
|
| 104 |
+
def _update_metrics(self, result: FetchResult) -> None:
|
| 105 |
+
outcome = "success"
|
| 106 |
+
if result.error:
|
| 107 |
+
outcome = "error"
|
| 108 |
+
elif result.is_demo:
|
| 109 |
+
outcome = "demo"
|
| 110 |
+
record_fetch(result.latency_ms, outcome)
|
| 111 |
+
|
| 112 |
+
if result.measurement:
|
| 113 |
+
now = datetime.now(self.settings.timezone)
|
| 114 |
+
age = max(0.0, (now - result.measurement.timestamp).total_seconds())
|
| 115 |
+
set_data_age(age)
|
| 116 |
+
else:
|
| 117 |
+
set_data_age(None)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
settings = get_settings()
|
| 121 |
+
cache = MeasurementCache()
|
| 122 |
+
service = PegelService(settings=settings, cache=cache)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
async def get_service() -> PegelService:
|
| 126 |
+
return service
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _warning_levels() -> list[dict[str, str | int]]:
|
| 130 |
+
return [
|
| 131 |
+
{"label": "Normal", "range": "< 400 cm", "color": "badge-normal", "min": 0, "max": 399},
|
| 132 |
+
{"label": "Aufmerksamkeit", "range": "400 – 599 cm", "color": "badge-attention", "min": 400, "max": 599},
|
| 133 |
+
{"label": "Warnung", "range": "600 – 799 cm", "color": "badge-warning", "min": 600, "max": 799},
|
| 134 |
+
{"label": "Alarm", "range": "≥ 800 cm", "color": "badge-alarm", "min": 800, "max": 9999},
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _sparkline_path(measurements: list[Measurement]) -> str:
|
| 139 |
+
if not measurements:
|
| 140 |
+
return ""
|
| 141 |
+
levels = [m.level_cm for m in measurements]
|
| 142 |
+
min_level = min(levels)
|
| 143 |
+
max_level = max(levels)
|
| 144 |
+
span = max(max_level - min_level, 1)
|
| 145 |
+
step = 100 / max(len(levels) - 1, 1)
|
| 146 |
+
points = []
|
| 147 |
+
for idx, level in enumerate(levels):
|
| 148 |
+
x = idx * step
|
| 149 |
+
y = 40 - ((level - min_level) / span * 40)
|
| 150 |
+
points.append(f"{x:.2f},{y:.2f}")
|
| 151 |
+
return "M " + " L ".join(points)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@app.get("/", response_class=HTMLResponse)
|
| 155 |
+
async def dashboard(
|
| 156 |
+
request: Request,
|
| 157 |
+
demo: Annotated[bool, Query(alias="demo")] = False,
|
| 158 |
+
service: PegelService = Depends(get_service),
|
| 159 |
+
) -> HTMLResponse:
|
| 160 |
+
if demo:
|
| 161 |
+
await service.fetch_and_update(force_demo=True)
|
| 162 |
+
|
| 163 |
+
latest = await service.latest()
|
| 164 |
+
if latest is None:
|
| 165 |
+
result = await service.fetch_and_update(force_demo=demo)
|
| 166 |
+
latest = result.measurement
|
| 167 |
+
|
| 168 |
+
history = await service.history()
|
| 169 |
+
sparkline = history[-24:]
|
| 170 |
+
sparkline_path = _sparkline_path(sparkline)
|
| 171 |
+
initial_payload = {
|
| 172 |
+
"latest": latest.model_dump(mode="json") if latest else None,
|
| 173 |
+
"history": [item.model_dump(mode="json") for item in history],
|
| 174 |
+
"autoRefresh": settings.refresh_seconds,
|
| 175 |
+
"demo": demo or (latest.is_demo if latest else False),
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
context = {
|
| 179 |
+
"request": request,
|
| 180 |
+
"latest": latest,
|
| 181 |
+
"history": history,
|
| 182 |
+
"sparkline": sparkline,
|
| 183 |
+
"sparkline_path": sparkline_path,
|
| 184 |
+
"auto_refresh_seconds": settings.refresh_seconds,
|
| 185 |
+
"timezone": settings.tz,
|
| 186 |
+
"warning_levels": _warning_levels(),
|
| 187 |
+
"demo_mode": demo or (latest.is_demo if latest else False),
|
| 188 |
+
"initial_payload": initial_payload,
|
| 189 |
+
}
|
| 190 |
+
return TEMPLATES.TemplateResponse("index.html.j2", context)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@app.get("/api/latest")
|
| 194 |
+
async def api_latest(
|
| 195 |
+
demo: Annotated[bool, Query(alias="demo")] = False,
|
| 196 |
+
service: PegelService = Depends(get_service),
|
| 197 |
+
) -> JSONResponse:
|
| 198 |
+
result = await service.fetch_and_update(force_demo=demo)
|
| 199 |
+
history = await service.history()
|
| 200 |
+
response = LatestResponse(
|
| 201 |
+
measurement=result.measurement,
|
| 202 |
+
history=history,
|
| 203 |
+
latency_ms=result.latency_ms,
|
| 204 |
+
error=result.error,
|
| 205 |
+
demo_mode=result.is_demo,
|
| 206 |
+
)
|
| 207 |
+
return JSONResponse(response.model_dump(mode="json"))
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@app.get("/api/history")
|
| 211 |
+
async def api_history(service: PegelService = Depends(get_service)) -> JSONResponse:
|
| 212 |
+
history = await service.history()
|
| 213 |
+
response = HistoryResponse(data=history, demo_mode=any(item.is_demo for item in history))
|
| 214 |
+
return JSONResponse(response.model_dump(mode="json"))
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@app.get("/healthz")
|
| 218 |
+
async def healthz(service: PegelService = Depends(get_service)) -> PlainTextResponse:
|
| 219 |
+
if not service.is_running():
|
| 220 |
+
raise HTTPException(status_code=503, detail="Background task not running")
|
| 221 |
+
return PlainTextResponse("ok")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@app.get("/metrics")
|
| 225 |
+
async def metrics_endpoint(service: PegelService = Depends(get_service)) -> PlainTextResponse:
|
| 226 |
+
latest = await service.latest()
|
| 227 |
+
if latest is None:
|
| 228 |
+
set_data_age(None)
|
| 229 |
+
else:
|
| 230 |
+
age = max(0.0, (datetime.now(settings.timezone) - latest.timestamp).total_seconds())
|
| 231 |
+
set_data_age(age)
|
| 232 |
+
data = generate_latest()
|
| 233 |
+
return PlainTextResponse(content=data.decode("utf-8"), media_type=CONTENT_TYPE_LATEST)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
import uvicorn
|
| 238 |
+
|
| 239 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=settings.port, reload=True)
|
app/metrics.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from prometheus_client import Counter, Gauge, Histogram
|
| 4 |
+
|
| 5 |
+
FETCH_LATENCY_HIST = Histogram(
|
| 6 |
+
"rheinpegel_fetch_latency_seconds",
|
| 7 |
+
"Latency for Rheinpegel upstream fetch operations",
|
| 8 |
+
)
|
| 9 |
+
FETCH_LAST_LATENCY = Gauge(
|
| 10 |
+
"rheinpegel_fetch_last_latency_seconds",
|
| 11 |
+
"Most recent upstream fetch latency in seconds",
|
| 12 |
+
)
|
| 13 |
+
FETCH_OUTCOME_COUNTER = Counter(
|
| 14 |
+
"rheinpegel_fetch_total",
|
| 15 |
+
"Count of Rheinpegel upstream fetch attempts by outcome",
|
| 16 |
+
labelnames=("outcome",),
|
| 17 |
+
)
|
| 18 |
+
DATA_AGE_GAUGE = Gauge(
|
| 19 |
+
"rheinpegel_data_age_seconds",
|
| 20 |
+
"Age of the latest Rheinpegel measurement in seconds",
|
| 21 |
+
)
|
| 22 |
+
BACKGROUND_LOOP_GAUGE = Gauge(
|
| 23 |
+
"rheinpegel_background_loop_running",
|
| 24 |
+
"Indicates whether the background polling task is running",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def record_fetch(latency_ms: float | None, outcome: str) -> None:
|
| 29 |
+
if latency_ms is not None:
|
| 30 |
+
latency_seconds = latency_ms / 1000.0
|
| 31 |
+
FETCH_LATENCY_HIST.observe(latency_seconds)
|
| 32 |
+
FETCH_LAST_LATENCY.set(latency_seconds)
|
| 33 |
+
FETCH_OUTCOME_COUNTER.labels(outcome=outcome).inc()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def set_data_age(age_seconds: float | None) -> None:
|
| 37 |
+
if age_seconds is None:
|
| 38 |
+
DATA_AGE_GAUGE.set(float("nan"))
|
| 39 |
+
else:
|
| 40 |
+
DATA_AGE_GAUGE.set(max(0.0, age_seconds))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def set_background_state(running: bool) -> None:
|
| 44 |
+
BACKGROUND_LOOP_GAUGE.set(1.0 if running else 0.0)
|
app/models.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from enum import Enum, IntEnum
|
| 5 |
+
from typing import Final
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Trend(IntEnum):
|
| 11 |
+
FALLING = -1
|
| 12 |
+
STABLE = 0
|
| 13 |
+
RISING = 1
|
| 14 |
+
|
| 15 |
+
@property
|
| 16 |
+
def symbol(self) -> str:
|
| 17 |
+
return {self.FALLING: "↓", self.STABLE: "→", self.RISING: "↑"}[self]
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def color(self) -> str:
|
| 21 |
+
return {self.FALLING: "badge-falling", self.STABLE: "badge-stable", self.RISING: "badge-rising"}[self]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class WarningLevel(str, Enum):
|
| 25 |
+
NORMAL = "normal"
|
| 26 |
+
ATTENTION = "attention"
|
| 27 |
+
WARNING = "warning"
|
| 28 |
+
ALARM = "alarm"
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def label(self) -> str:
|
| 32 |
+
return {
|
| 33 |
+
self.NORMAL: "Normal",
|
| 34 |
+
self.ATTENTION: "Aufmerksamkeit",
|
| 35 |
+
self.WARNING: "Warnung",
|
| 36 |
+
self.ALARM: "Alarm",
|
| 37 |
+
}[self]
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def color(self) -> str:
|
| 41 |
+
return {
|
| 42 |
+
self.NORMAL: "badge-normal",
|
| 43 |
+
self.ATTENTION: "badge-attention",
|
| 44 |
+
self.WARNING: "badge-warning",
|
| 45 |
+
self.ALARM: "badge-alarm",
|
| 46 |
+
}[self]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
WARN_THRESHOLDS: Final[tuple[tuple[int, WarningLevel], ...]] = (
|
| 50 |
+
(400, WarningLevel.NORMAL),
|
| 51 |
+
(600, WarningLevel.ATTENTION),
|
| 52 |
+
(800, WarningLevel.WARNING),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def determine_warning(level_cm: int) -> WarningLevel:
|
| 57 |
+
for threshold, level in WARN_THRESHOLDS:
|
| 58 |
+
if level_cm < threshold:
|
| 59 |
+
return level
|
| 60 |
+
return WarningLevel.ALARM
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Measurement(BaseModel):
|
| 64 |
+
model_config = ConfigDict(frozen=True)
|
| 65 |
+
|
| 66 |
+
level_cm: int = Field(ge=0, description="Water level in centimeters")
|
| 67 |
+
timestamp: datetime = Field(description="Timestamp of the measurement with tz info")
|
| 68 |
+
trend: Trend = Field(description="Trend indicator: -1 falling, 0 stable, 1 rising")
|
| 69 |
+
is_demo: bool = False
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def warning(self) -> WarningLevel:
|
| 73 |
+
return determine_warning(self.level_cm)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class HistoryResponse(BaseModel):
|
| 77 |
+
data: list[Measurement]
|
| 78 |
+
demo_mode: bool = False
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class LatestResponse(BaseModel):
|
| 82 |
+
measurement: Measurement
|
| 83 |
+
history: list[Measurement] = Field(default_factory=list)
|
| 84 |
+
latency_ms: float | None = None
|
| 85 |
+
error: str | None = None
|
| 86 |
+
demo_mode: bool = False
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def resolve_trend(
|
| 90 |
+
current_level: int,
|
| 91 |
+
provided_trend: int | None,
|
| 92 |
+
previous: Measurement | None,
|
| 93 |
+
) -> Trend:
|
| 94 |
+
if provided_trend in (-1, 0, 1):
|
| 95 |
+
return Trend(provided_trend)
|
| 96 |
+
if previous is None:
|
| 97 |
+
return Trend.STABLE
|
| 98 |
+
|
| 99 |
+
delta = current_level - previous.level_cm
|
| 100 |
+
if delta > 2:
|
| 101 |
+
return Trend.RISING
|
| 102 |
+
if delta < -2:
|
| 103 |
+
return Trend.FALLING
|
| 104 |
+
return Trend.STABLE
|
| 105 |
+
|
app/static/app.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
const script = document.currentScript;
|
| 3 |
+
if (!script) {
|
| 4 |
+
return;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
const apiLatest = script.dataset.apiLatest;
|
| 8 |
+
const refreshSeconds = Number.parseInt(script.dataset.refresh ?? "120", 10) || 120;
|
| 9 |
+
const demoFlag = script.dataset.demo === "true";
|
| 10 |
+
const timezone = document.body?.dataset?.timezone || "Europe/Berlin";
|
| 11 |
+
|
| 12 |
+
const levelValue = document.getElementById("levelValue");
|
| 13 |
+
const warningBadge = document.getElementById("warningBadge");
|
| 14 |
+
const trendBadge = document.getElementById("trendBadge");
|
| 15 |
+
const trendSymbol = document.getElementById("trendSymbol");
|
| 16 |
+
const updatedTime = document.getElementById("updatedTime");
|
| 17 |
+
const relativeAge = document.getElementById("relativeAge");
|
| 18 |
+
const countdown = document.getElementById("countdown");
|
| 19 |
+
const refreshButton = document.getElementById("refreshButton");
|
| 20 |
+
const autoToggle = document.getElementById("autoRefreshToggle");
|
| 21 |
+
const sparklinePath = document.getElementById("sparklinePath");
|
| 22 |
+
const errorBanner = document.getElementById("errorBanner");
|
| 23 |
+
const demoBadge = document.getElementById("demoBadge");
|
| 24 |
+
|
| 25 |
+
const initialDataEl = document.getElementById("initial-data");
|
| 26 |
+
let initialPayload = { latest: null, history: [], autoRefresh: refreshSeconds, demo: demoFlag };
|
| 27 |
+
if (initialDataEl?.textContent) {
|
| 28 |
+
try {
|
| 29 |
+
initialPayload = JSON.parse(initialDataEl.textContent);
|
| 30 |
+
} catch (err) {
|
| 31 |
+
console.warn("Failed to parse initial payload", err);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const state = {
|
| 36 |
+
autoRefresh: true,
|
| 37 |
+
secondsRemaining: refreshSeconds,
|
| 38 |
+
lastMeasurement: initialPayload.latest ?? null,
|
| 39 |
+
history: initialPayload.history ?? [],
|
| 40 |
+
demoMode: demoFlag || Boolean(initialPayload.demo),
|
| 41 |
+
timezone,
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
| 45 |
+
timeZone: timezone,
|
| 46 |
+
year: "numeric",
|
| 47 |
+
month: "2-digit",
|
| 48 |
+
day: "2-digit",
|
| 49 |
+
hour: "2-digit",
|
| 50 |
+
minute: "2-digit",
|
| 51 |
+
second: "2-digit",
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
function showError(message) {
|
| 55 |
+
if (!errorBanner) return;
|
| 56 |
+
errorBanner.textContent = message;
|
| 57 |
+
errorBanner.hidden = false;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function clearError() {
|
| 61 |
+
if (!errorBanner) return;
|
| 62 |
+
errorBanner.textContent = "";
|
| 63 |
+
errorBanner.hidden = true;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function warningFor(level) {
|
| 67 |
+
if (level < 400) {
|
| 68 |
+
return { label: "Normal", cls: "badge-normal" };
|
| 69 |
+
}
|
| 70 |
+
if (level < 600) {
|
| 71 |
+
return { label: "Aufmerksamkeit", cls: "badge-attention" };
|
| 72 |
+
}
|
| 73 |
+
if (level < 800) {
|
| 74 |
+
return { label: "Warnung", cls: "badge-warning" };
|
| 75 |
+
}
|
| 76 |
+
return { label: "Alarm", cls: "badge-alarm" };
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function trendInfo(trendValue) {
|
| 80 |
+
switch (trendValue) {
|
| 81 |
+
case 1:
|
| 82 |
+
return { label: "steigend", symbol: "↑", cls: "badge-rising" };
|
| 83 |
+
case -1:
|
| 84 |
+
return { label: "fallend", symbol: "↓", cls: "badge-falling" };
|
| 85 |
+
default:
|
| 86 |
+
return { label: "gleichbleibend", symbol: "→", cls: "badge-stable" };
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function computeSparkline(history) {
|
| 91 |
+
if (!history || history.length === 0) {
|
| 92 |
+
return "";
|
| 93 |
+
}
|
| 94 |
+
const levels = history.map((entry) => Number(entry.level_cm ?? entry.levelCm)).filter((v) => Number.isFinite(v));
|
| 95 |
+
if (levels.length === 0) {
|
| 96 |
+
return "";
|
| 97 |
+
}
|
| 98 |
+
const min = Math.min(...levels);
|
| 99 |
+
const max = Math.max(...levels);
|
| 100 |
+
const span = Math.max(max - min, 1);
|
| 101 |
+
const step = 100 / Math.max(levels.length - 1, 1);
|
| 102 |
+
const parts = levels.map((level, index) => {
|
| 103 |
+
const x = index * step;
|
| 104 |
+
const y = 40 - ((level - min) / span) * 40;
|
| 105 |
+
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
| 106 |
+
});
|
| 107 |
+
return `M ${parts.join(" L ")}`;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function updateDemoBadge(isDemo) {
|
| 111 |
+
if (!demoBadge) return;
|
| 112 |
+
if (isDemo) {
|
| 113 |
+
demoBadge.hidden = false;
|
| 114 |
+
document.body?.setAttribute("data-demo", "true");
|
| 115 |
+
} else {
|
| 116 |
+
demoBadge.hidden = true;
|
| 117 |
+
document.body?.setAttribute("data-demo", "false");
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function updateUI(measurement, history) {
|
| 122 |
+
if (!measurement) {
|
| 123 |
+
showError("Keine Messdaten verfügbar");
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
state.lastMeasurement = measurement;
|
| 127 |
+
state.history = history ?? state.history;
|
| 128 |
+
clearError();
|
| 129 |
+
|
| 130 |
+
if (levelValue) {
|
| 131 |
+
levelValue.textContent = measurement.level_cm ?? measurement.levelCm;
|
| 132 |
+
}
|
| 133 |
+
const warning = warningFor(measurement.level_cm ?? measurement.levelCm);
|
| 134 |
+
if (warningBadge) {
|
| 135 |
+
warningBadge.className = `badge ${warning.cls}`;
|
| 136 |
+
warningBadge.textContent = warning.label;
|
| 137 |
+
}
|
| 138 |
+
const trend = trendInfo(measurement.trend);
|
| 139 |
+
if (trendBadge) {
|
| 140 |
+
trendBadge.className = `badge trend-badge ${trend.cls}`;
|
| 141 |
+
trendBadge.setAttribute("aria-label", `Trend ${trend.label}`);
|
| 142 |
+
}
|
| 143 |
+
if (trendSymbol) {
|
| 144 |
+
trendSymbol.textContent = trend.symbol;
|
| 145 |
+
}
|
| 146 |
+
if (updatedTime) {
|
| 147 |
+
updatedTime.dataset.timestamp = measurement.timestamp;
|
| 148 |
+
const date = new Date(measurement.timestamp);
|
| 149 |
+
updatedTime.textContent = `Zuletzt aktualisiert: ${dateFormatter.format(date)} Uhr`;
|
| 150 |
+
}
|
| 151 |
+
updateRelativeAge();
|
| 152 |
+
updateDemoBadge(Boolean(measurement.is_demo ?? measurement.isDemo));
|
| 153 |
+
|
| 154 |
+
if (sparklinePath) {
|
| 155 |
+
sparklinePath.setAttribute("d", computeSparkline(state.history.slice(-24)));
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function updateRelativeAge() {
|
| 160 |
+
if (!relativeAge || !state.lastMeasurement?.timestamp) {
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
const lastDate = new Date(state.lastMeasurement.timestamp);
|
| 164 |
+
const diffSeconds = Math.max(0, Math.floor((Date.now() - lastDate.getTime()) / 1000));
|
| 165 |
+
if (diffSeconds < 5) {
|
| 166 |
+
relativeAge.textContent = "vor wenigen Sekunden";
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
if (diffSeconds < 60) {
|
| 170 |
+
relativeAge.textContent = `vor ${diffSeconds} Sekunden`;
|
| 171 |
+
return;
|
| 172 |
+
}
|
| 173 |
+
const minutes = Math.floor(diffSeconds / 60);
|
| 174 |
+
const seconds = diffSeconds % 60;
|
| 175 |
+
relativeAge.textContent = `vor ${minutes} Minute${minutes === 1 ? "" : "n"} und ${seconds} Sekunde${seconds === 1 ? "" : "n"}`;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function updateCountdown() {
|
| 179 |
+
if (!countdown) return;
|
| 180 |
+
if (!state.autoRefresh) {
|
| 181 |
+
countdown.textContent = "Auto-Refresh deaktiviert";
|
| 182 |
+
return;
|
| 183 |
+
}
|
| 184 |
+
const minutes = Math.floor(state.secondsRemaining / 60);
|
| 185 |
+
const seconds = state.secondsRemaining % 60;
|
| 186 |
+
countdown.textContent = `Nächste Aktualisierung in ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
async function refreshNow() {
|
| 190 |
+
if (!apiLatest) return;
|
| 191 |
+
if (refreshButton) {
|
| 192 |
+
refreshButton.disabled = true;
|
| 193 |
+
}
|
| 194 |
+
try {
|
| 195 |
+
const url = new URL(apiLatest, window.location.origin);
|
| 196 |
+
if (state.demoMode) {
|
| 197 |
+
url.searchParams.set("demo", "1");
|
| 198 |
+
}
|
| 199 |
+
const response = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
| 200 |
+
if (!response.ok) {
|
| 201 |
+
throw new Error(`Serverfehler ${response.status}`);
|
| 202 |
+
}
|
| 203 |
+
const payload = await response.json();
|
| 204 |
+
if (payload.error) {
|
| 205 |
+
showError(payload.error);
|
| 206 |
+
} else {
|
| 207 |
+
clearError();
|
| 208 |
+
}
|
| 209 |
+
state.demoMode = Boolean(payload.demo_mode ?? payload.demoMode ?? state.demoMode);
|
| 210 |
+
updateUI(payload.measurement, payload.history);
|
| 211 |
+
state.secondsRemaining = refreshSeconds;
|
| 212 |
+
updateCountdown();
|
| 213 |
+
} catch (error) {
|
| 214 |
+
console.error("Aktualisierung fehlgeschlagen", error);
|
| 215 |
+
showError("Aktualisierung fehlgeschlagen. Bitte später erneut versuchen.");
|
| 216 |
+
} finally {
|
| 217 |
+
if (refreshButton) {
|
| 218 |
+
refreshButton.disabled = false;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if (autoToggle) {
|
| 224 |
+
autoToggle.addEventListener("change", (event) => {
|
| 225 |
+
state.autoRefresh = event.target.checked;
|
| 226 |
+
autoToggle.setAttribute("aria-checked", state.autoRefresh ? "true" : "false");
|
| 227 |
+
if (state.autoRefresh) {
|
| 228 |
+
state.secondsRemaining = refreshSeconds;
|
| 229 |
+
}
|
| 230 |
+
updateCountdown();
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
if (refreshButton) {
|
| 235 |
+
refreshButton.addEventListener("click", () => {
|
| 236 |
+
refreshNow();
|
| 237 |
+
});
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
setInterval(() => {
|
| 241 |
+
if (!state.autoRefresh) {
|
| 242 |
+
return;
|
| 243 |
+
}
|
| 244 |
+
state.secondsRemaining -= 1;
|
| 245 |
+
if (state.secondsRemaining <= 0) {
|
| 246 |
+
state.secondsRemaining = refreshSeconds;
|
| 247 |
+
refreshNow();
|
| 248 |
+
}
|
| 249 |
+
updateCountdown();
|
| 250 |
+
}, 1000);
|
| 251 |
+
|
| 252 |
+
setInterval(updateRelativeAge, 1000);
|
| 253 |
+
|
| 254 |
+
updateUI(state.lastMeasurement, state.history);
|
| 255 |
+
updateCountdown();
|
| 256 |
+
})();
|
app/static/styles.css
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light dark;
|
| 3 |
+
--bg-color: #f8fafc;
|
| 4 |
+
--text-color: #0f172a;
|
| 5 |
+
--card-bg: #ffffff;
|
| 6 |
+
--border-color: #e2e8f0;
|
| 7 |
+
--accent: #2563eb;
|
| 8 |
+
--accent-hover: #1d4ed8;
|
| 9 |
+
--badge-normal: #15803d;
|
| 10 |
+
--badge-attention: #f97316;
|
| 11 |
+
--badge-warning: #ea580c;
|
| 12 |
+
--badge-alarm: #dc2626;
|
| 13 |
+
--badge-stable: #64748b;
|
| 14 |
+
--badge-rising: #16a34a;
|
| 15 |
+
--badge-falling: #0ea5e9;
|
| 16 |
+
--badge-demo: #7c3aed;
|
| 17 |
+
--muted: #94a3b8;
|
| 18 |
+
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@media (prefers-color-scheme: dark) {
|
| 22 |
+
:root {
|
| 23 |
+
--bg-color: #0f172a;
|
| 24 |
+
--text-color: #e2e8f0;
|
| 25 |
+
--card-bg: #1e293b;
|
| 26 |
+
--border-color: #334155;
|
| 27 |
+
--accent: #3b82f6;
|
| 28 |
+
--accent-hover: #2563eb;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
* {
|
| 33 |
+
box-sizing: border-box;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
body.page {
|
| 37 |
+
margin: 0;
|
| 38 |
+
padding: 0 1.5rem;
|
| 39 |
+
font-family: var(--font-sans);
|
| 40 |
+
background: var(--bg-color);
|
| 41 |
+
color: var(--text-color);
|
| 42 |
+
min-height: 100vh;
|
| 43 |
+
display: flex;
|
| 44 |
+
flex-direction: column;
|
| 45 |
+
gap: 2rem;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.site-header {
|
| 49 |
+
padding-top: 2rem;
|
| 50 |
+
display: flex;
|
| 51 |
+
justify-content: space-between;
|
| 52 |
+
align-items: center;
|
| 53 |
+
flex-wrap: wrap;
|
| 54 |
+
gap: 1rem;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.title-block h1 {
|
| 58 |
+
font-size: clamp(2rem, 4vw, 2.75rem);
|
| 59 |
+
margin: 0;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.subtitle {
|
| 63 |
+
margin: 0;
|
| 64 |
+
font-size: 1rem;
|
| 65 |
+
color: var(--muted);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.layout {
|
| 69 |
+
display: grid;
|
| 70 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 71 |
+
gap: 1.5rem;
|
| 72 |
+
width: min(1100px, 100%);
|
| 73 |
+
margin: 0 auto;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.card {
|
| 77 |
+
background: var(--card-bg);
|
| 78 |
+
border: 1px solid var(--border-color);
|
| 79 |
+
border-radius: 18px;
|
| 80 |
+
padding: 1.5rem;
|
| 81 |
+
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.alert {
|
| 85 |
+
margin-bottom: 1rem;
|
| 86 |
+
padding: 0.75rem 1rem;
|
| 87 |
+
border-radius: 12px;
|
| 88 |
+
background: rgba(220, 38, 38, 0.12);
|
| 89 |
+
color: #dc2626;
|
| 90 |
+
font-weight: 600;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.alert[hidden] {
|
| 94 |
+
display: none;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.card-header {
|
| 98 |
+
display: flex;
|
| 99 |
+
align-items: center;
|
| 100 |
+
justify-content: space-between;
|
| 101 |
+
gap: 1rem;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.card-header h2 {
|
| 105 |
+
margin: 0;
|
| 106 |
+
font-size: 1.35rem;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.level-display {
|
| 110 |
+
display: flex;
|
| 111 |
+
align-items: center;
|
| 112 |
+
gap: 1rem;
|
| 113 |
+
margin: 1.5rem 0;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.level-number {
|
| 117 |
+
font-size: clamp(3rem, 6vw, 4.5rem);
|
| 118 |
+
font-weight: 700;
|
| 119 |
+
line-height: 1;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.level-number .unit {
|
| 123 |
+
font-size: 1rem;
|
| 124 |
+
margin-left: 0.25rem;
|
| 125 |
+
color: var(--muted);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.badge {
|
| 129 |
+
display: inline-flex;
|
| 130 |
+
align-items: center;
|
| 131 |
+
gap: 0.35rem;
|
| 132 |
+
padding: 0.35rem 0.75rem;
|
| 133 |
+
border-radius: 999px;
|
| 134 |
+
font-size: 0.85rem;
|
| 135 |
+
font-weight: 600;
|
| 136 |
+
text-transform: uppercase;
|
| 137 |
+
letter-spacing: 0.05em;
|
| 138 |
+
background: var(--border-color);
|
| 139 |
+
color: var(--text-color);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.badge-normal { background: rgba(21, 128, 61, 0.15); color: #15803d; }
|
| 143 |
+
.badge-attention { background: rgba(249, 115, 22, 0.18); color: #f97316; }
|
| 144 |
+
.badge-warning { background: rgba(234, 88, 12, 0.18); color: #ea580c; }
|
| 145 |
+
.badge-alarm { background: rgba(220, 38, 38, 0.18); color: #dc2626; }
|
| 146 |
+
.badge-stable { background: rgba(100, 116, 139, 0.18); color: #475569; }
|
| 147 |
+
.badge-rising { background: rgba(22, 163, 74, 0.18); color: #16a34a; }
|
| 148 |
+
.badge-falling { background: rgba(14, 165, 233, 0.18); color: #0284c7; }
|
| 149 |
+
.badge-demo { background: rgba(124, 58, 237, 0.18); color: #7c3aed; }
|
| 150 |
+
.badge-muted { background: rgba(148, 163, 184, 0.2); color: #475569; }
|
| 151 |
+
|
| 152 |
+
.trend-badge {
|
| 153 |
+
font-size: 1.25rem;
|
| 154 |
+
min-width: 3rem;
|
| 155 |
+
justify-content: center;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.status-meta {
|
| 159 |
+
display: flex;
|
| 160 |
+
flex-wrap: wrap;
|
| 161 |
+
gap: 0.75rem;
|
| 162 |
+
color: var(--muted);
|
| 163 |
+
font-size: 0.95rem;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.controls {
|
| 167 |
+
display: flex;
|
| 168 |
+
align-items: center;
|
| 169 |
+
gap: 1rem;
|
| 170 |
+
flex-wrap: wrap;
|
| 171 |
+
margin: 1.5rem 0 1rem;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.btn {
|
| 175 |
+
border: none;
|
| 176 |
+
border-radius: 999px;
|
| 177 |
+
padding: 0.65rem 1.5rem;
|
| 178 |
+
font-size: 1rem;
|
| 179 |
+
font-weight: 600;
|
| 180 |
+
cursor: pointer;
|
| 181 |
+
transition: background 0.2s ease, transform 0.1s ease;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.btn.primary {
|
| 185 |
+
background: var(--accent);
|
| 186 |
+
color: #fff;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.btn.primary:focus-visible,
|
| 190 |
+
.btn.primary:hover {
|
| 191 |
+
background: var(--accent-hover);
|
| 192 |
+
transform: translateY(-1px);
|
| 193 |
+
outline: 2px solid transparent;
|
| 194 |
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.3);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.toggle {
|
| 198 |
+
display: inline-flex;
|
| 199 |
+
align-items: center;
|
| 200 |
+
gap: 0.5rem;
|
| 201 |
+
font-weight: 500;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.toggle input {
|
| 205 |
+
width: 2.5rem;
|
| 206 |
+
height: 1.4rem;
|
| 207 |
+
appearance: none;
|
| 208 |
+
background: var(--border-color);
|
| 209 |
+
border-radius: 999px;
|
| 210 |
+
position: relative;
|
| 211 |
+
cursor: pointer;
|
| 212 |
+
transition: background 0.2s ease;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.toggle input::after {
|
| 216 |
+
content: "";
|
| 217 |
+
position: absolute;
|
| 218 |
+
top: 0.2rem;
|
| 219 |
+
left: 0.25rem;
|
| 220 |
+
width: 1rem;
|
| 221 |
+
height: 1rem;
|
| 222 |
+
border-radius: 50%;
|
| 223 |
+
background: #fff;
|
| 224 |
+
transition: transform 0.2s ease;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.toggle input:checked {
|
| 228 |
+
background: var(--accent);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.toggle input:checked::after {
|
| 232 |
+
transform: translateX(1.05rem);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.toggle input:focus-visible {
|
| 236 |
+
outline: 2px solid var(--accent);
|
| 237 |
+
outline-offset: 2px;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.sparkline {
|
| 241 |
+
margin: 0;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.sparkline svg {
|
| 245 |
+
width: 100%;
|
| 246 |
+
height: 120px;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.sparkline path {
|
| 250 |
+
fill: none;
|
| 251 |
+
stroke: var(--accent);
|
| 252 |
+
stroke-width: 2;
|
| 253 |
+
stroke-linejoin: round;
|
| 254 |
+
stroke-linecap: round;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.sparkline .baseline {
|
| 258 |
+
stroke: rgba(148, 163, 184, 0.3);
|
| 259 |
+
stroke-width: 1;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.legend-card {
|
| 263 |
+
display: flex;
|
| 264 |
+
flex-direction: column;
|
| 265 |
+
gap: 1rem;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.legend-list {
|
| 269 |
+
list-style: none;
|
| 270 |
+
margin: 0;
|
| 271 |
+
padding: 0;
|
| 272 |
+
display: flex;
|
| 273 |
+
flex-direction: column;
|
| 274 |
+
gap: 0.75rem;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.legend-item {
|
| 278 |
+
display: flex;
|
| 279 |
+
align-items: center;
|
| 280 |
+
gap: 0.75rem;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.legend-label {
|
| 284 |
+
font-weight: 600;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.legend-range {
|
| 288 |
+
color: var(--muted);
|
| 289 |
+
font-size: 0.9rem;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.hint {
|
| 293 |
+
margin: 0;
|
| 294 |
+
font-size: 0.85rem;
|
| 295 |
+
color: var(--muted);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.site-footer {
|
| 299 |
+
margin-top: auto;
|
| 300 |
+
padding-bottom: 2rem;
|
| 301 |
+
text-align: center;
|
| 302 |
+
color: var(--muted);
|
| 303 |
+
font-size: 0.85rem;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
button:focus-visible,
|
| 307 |
+
input:focus-visible,
|
| 308 |
+
a:focus-visible {
|
| 309 |
+
outline: 3px solid rgba(37, 99, 235, 0.5);
|
| 310 |
+
outline-offset: 2px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.sr-only {
|
| 314 |
+
position: absolute;
|
| 315 |
+
width: 1px;
|
| 316 |
+
height: 1px;
|
| 317 |
+
padding: 0;
|
| 318 |
+
margin: -1px;
|
| 319 |
+
overflow: hidden;
|
| 320 |
+
clip: rect(0, 0, 0, 0);
|
| 321 |
+
border: 0;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
@media (max-width: 640px) {
|
| 325 |
+
body.page {
|
| 326 |
+
padding: 0 1rem;
|
| 327 |
+
}
|
| 328 |
+
.controls {
|
| 329 |
+
justify-content: space-between;
|
| 330 |
+
}
|
| 331 |
+
}
|
app/templates/index.html.j2
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="de">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Rheinpegel Köln</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 8 |
+
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}" />
|
| 9 |
+
<script
|
| 10 |
+
defer
|
| 11 |
+
src="{{ url_for('static', path='app.js') }}"
|
| 12 |
+
data-api-latest="{{ url_for('api_latest') }}"
|
| 13 |
+
data-api-history="{{ url_for('api_history') }}"
|
| 14 |
+
data-refresh="{{ auto_refresh_seconds }}"
|
| 15 |
+
data-demo="{{ 'true' if demo_mode else 'false' }}"
|
| 16 |
+
></script>
|
| 17 |
+
</head>
|
| 18 |
+
<body class="page" data-demo="{{ 'true' if demo_mode else 'false' }}" data-timezone="{{ timezone }}">
|
| 19 |
+
<header class="site-header">
|
| 20 |
+
<div class="title-block">
|
| 21 |
+
<h1>Rheinpegel Köln</h1>
|
| 22 |
+
<p class="subtitle">Aktueller Wasserstand, Trend und Verlauf der letzten 48 Messpunkte</p>
|
| 23 |
+
</div>
|
| 24 |
+
<span id="demoBadge" class="badge badge-demo" {% if not demo_mode %}hidden{% endif %} aria-live="polite">Demo-Modus aktiv</span>
|
| 25 |
+
</header>
|
| 26 |
+
<main class="layout">
|
| 27 |
+
<section class="card status-card" aria-labelledby="status-heading">
|
| 28 |
+
<header class="card-header">
|
| 29 |
+
<h2 id="status-heading">Aktueller Pegel</h2>
|
| 30 |
+
{% if latest %}
|
| 31 |
+
<span id="warningBadge" class="badge {{ latest.warning.color }}">{{ latest.warning.label }}</span>
|
| 32 |
+
{% else %}
|
| 33 |
+
<span id="warningBadge" class="badge badge-muted">Keine Daten</span>
|
| 34 |
+
{% endif %}
|
| 35 |
+
</header>
|
| 36 |
+
<div id="errorBanner" class="alert" role="alert" hidden aria-live="assertive"></div>
|
| 37 |
+
<div class="level-display">
|
| 38 |
+
<span class="level-number" aria-live="polite">
|
| 39 |
+
<span id="levelValue" class="level-number-value">{% if latest %}{{ latest.level_cm }}{% else %}–{% endif %}</span>
|
| 40 |
+
<span class="unit">cm</span>
|
| 41 |
+
</span>
|
| 42 |
+
{% set trend_class = 'badge-stable' %}
|
| 43 |
+
{% set trend_label = 'gleichbleibend' %}
|
| 44 |
+
{% set trend_symbol = '→' %}
|
| 45 |
+
{% if latest %}
|
| 46 |
+
{% if latest.trend.value == 1 %}
|
| 47 |
+
{% set trend_class = 'badge-rising' %}
|
| 48 |
+
{% set trend_label = 'steigend' %}
|
| 49 |
+
{% set trend_symbol = '↑' %}
|
| 50 |
+
{% elif latest.trend.value == -1 %}
|
| 51 |
+
{% set trend_class = 'badge-falling' %}
|
| 52 |
+
{% set trend_label = 'fallend' %}
|
| 53 |
+
{% set trend_symbol = '↓' %}
|
| 54 |
+
{% endif %}
|
| 55 |
+
{% endif %}
|
| 56 |
+
<span id="trendBadge" class="badge trend-badge {{ trend_class }}" aria-live="polite" aria-label="Trend {{ trend_label }}">
|
| 57 |
+
<span id="trendSymbol">{{ trend_symbol }}</span>
|
| 58 |
+
<span id="trendText" class="sr-only">{{ trend_label }}</span>
|
| 59 |
+
</span>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="status-meta" aria-live="polite">
|
| 62 |
+
<span id="updatedTime" data-timestamp="{% if latest %}{{ latest.timestamp.isoformat() }}{% endif %}">
|
| 63 |
+
{% if latest %}
|
| 64 |
+
Zuletzt aktualisiert: {{ latest.timestamp.astimezone().strftime('%d.%m.%Y %H:%M:%S') }} Uhr
|
| 65 |
+
{% else %}
|
| 66 |
+
Keine Messwerte verfügbar
|
| 67 |
+
{% endif %}
|
| 68 |
+
</span>
|
| 69 |
+
<span id="relativeAge">{% if latest %}vor wenigen Sekunden{% endif %}</span>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="controls" role="group" aria-label="Datenaktualisierung">
|
| 72 |
+
<button type="button" id="refreshButton" class="btn primary">Jetzt aktualisieren</button>
|
| 73 |
+
<label class="toggle" for="autoRefreshToggle">
|
| 74 |
+
<input type="checkbox" id="autoRefreshToggle" checked aria-checked="true" />
|
| 75 |
+
<span>Auto-Refresh</span>
|
| 76 |
+
</label>
|
| 77 |
+
<span id="countdown" aria-live="polite">Nächste Aktualisierung in {{ '%02d:%02d'|format((auto_refresh_seconds // 60), (auto_refresh_seconds % 60)) }}</span>
|
| 78 |
+
</div>
|
| 79 |
+
<figure class="sparkline" role="img" aria-label="Sparkline der letzten 24 Messwerte">
|
| 80 |
+
<svg id="sparklineChart" viewBox="0 0 100 40" preserveAspectRatio="none">
|
| 81 |
+
<path id="sparklinePath" d="{{ sparkline_path }}" />
|
| 82 |
+
<line class="baseline" x1="0" x2="100" y1="40" y2="40" />
|
| 83 |
+
</svg>
|
| 84 |
+
<figcaption>Verlauf der letzten 24 Messungen</figcaption>
|
| 85 |
+
</figure>
|
| 86 |
+
</section>
|
| 87 |
+
<section class="card legend-card" aria-labelledby="legend-heading">
|
| 88 |
+
<h2 id="legend-heading">Warnstufen</h2>
|
| 89 |
+
<ul class="legend-list">
|
| 90 |
+
{% for level in warning_levels %}
|
| 91 |
+
<li class="legend-item">
|
| 92 |
+
<span class="badge {{ level.color }}" aria-hidden="true"></span>
|
| 93 |
+
<span class="legend-label">{{ level.label }}</span>
|
| 94 |
+
<span class="legend-range">{{ level.range }}</span>
|
| 95 |
+
</li>
|
| 96 |
+
{% endfor %}
|
| 97 |
+
</ul>
|
| 98 |
+
<p class="hint">Trend an/aus beeinflusst nur die Anzeige im Browser. Serverseitige Aktualisierung läuft unabhängig.</p>
|
| 99 |
+
</section>
|
| 100 |
+
</main>
|
| 101 |
+
<footer class="site-footer">
|
| 102 |
+
<small>Quelle: Stadt Köln – aktualisiert alle {{ auto_refresh_seconds }} Sekunden. Zeitzone: {{ timezone }}.</small>
|
| 103 |
+
</footer>
|
| 104 |
+
<script type="application/json" id="initial-data">{{ initial_payload | tojson }}</script>
|
| 105 |
+
</body>
|
| 106 |
+
</html>
|
data/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
data/cache.json
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"level_cm": 368,
|
| 4 |
+
"timestamp": "2025-10-27T17:20:00+01:00",
|
| 5 |
+
"trend": 0,
|
| 6 |
+
"is_demo": false
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"level_cm": 368,
|
| 10 |
+
"timestamp": "2025-10-27T17:25:00+01:00",
|
| 11 |
+
"trend": 0,
|
| 12 |
+
"is_demo": false
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"level_cm": 368,
|
| 16 |
+
"timestamp": "2025-10-27T17:30:00+01:00",
|
| 17 |
+
"trend": 0,
|
| 18 |
+
"is_demo": false
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"level_cm": 368,
|
| 22 |
+
"timestamp": "2025-10-27T17:35:00+01:00",
|
| 23 |
+
"trend": 0,
|
| 24 |
+
"is_demo": false
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"level_cm": 368,
|
| 28 |
+
"timestamp": "2025-10-27T17:40:00+01:00",
|
| 29 |
+
"trend": 0,
|
| 30 |
+
"is_demo": false
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"level_cm": 368,
|
| 34 |
+
"timestamp": "2025-10-27T17:45:00+01:00",
|
| 35 |
+
"trend": 0,
|
| 36 |
+
"is_demo": false
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"level_cm": 368,
|
| 40 |
+
"timestamp": "2025-10-27T17:50:00+01:00",
|
| 41 |
+
"trend": 0,
|
| 42 |
+
"is_demo": false
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"level_cm": 368,
|
| 46 |
+
"timestamp": "2025-10-27T17:55:00+01:00",
|
| 47 |
+
"trend": 0,
|
| 48 |
+
"is_demo": false
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"level_cm": 368,
|
| 52 |
+
"timestamp": "2025-10-27T18:00:00+01:00",
|
| 53 |
+
"trend": 0,
|
| 54 |
+
"is_demo": false
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"level_cm": 368,
|
| 58 |
+
"timestamp": "2025-10-27T18:05:00+01:00",
|
| 59 |
+
"trend": 0,
|
| 60 |
+
"is_demo": false
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"level_cm": 368,
|
| 64 |
+
"timestamp": "2025-10-27T18:10:00+01:00",
|
| 65 |
+
"trend": 0,
|
| 66 |
+
"is_demo": false
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"level_cm": 368,
|
| 70 |
+
"timestamp": "2025-10-27T18:15:00+01:00",
|
| 71 |
+
"trend": 0,
|
| 72 |
+
"is_demo": false
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"level_cm": 368,
|
| 76 |
+
"timestamp": "2025-10-27T18:20:00+01:00",
|
| 77 |
+
"trend": 0,
|
| 78 |
+
"is_demo": false
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"level_cm": 368,
|
| 82 |
+
"timestamp": "2025-10-27T18:25:00+01:00",
|
| 83 |
+
"trend": 0,
|
| 84 |
+
"is_demo": false
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"level_cm": 368,
|
| 88 |
+
"timestamp": "2025-10-27T18:30:00+01:00",
|
| 89 |
+
"trend": 0,
|
| 90 |
+
"is_demo": false
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"level_cm": 368,
|
| 94 |
+
"timestamp": "2025-10-27T18:35:00+01:00",
|
| 95 |
+
"trend": 0,
|
| 96 |
+
"is_demo": false
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"level_cm": 368,
|
| 100 |
+
"timestamp": "2025-10-27T18:40:00+01:00",
|
| 101 |
+
"trend": 0,
|
| 102 |
+
"is_demo": false
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"level_cm": 368,
|
| 106 |
+
"timestamp": "2025-10-27T18:45:00+01:00",
|
| 107 |
+
"trend": 0,
|
| 108 |
+
"is_demo": false
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"level_cm": 369,
|
| 112 |
+
"timestamp": "2025-10-27T18:50:00+01:00",
|
| 113 |
+
"trend": 0,
|
| 114 |
+
"is_demo": false
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"level_cm": 369,
|
| 118 |
+
"timestamp": "2025-10-27T18:55:00+01:00",
|
| 119 |
+
"trend": 0,
|
| 120 |
+
"is_demo": false
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"level_cm": 369,
|
| 124 |
+
"timestamp": "2025-10-27T19:00:00+01:00",
|
| 125 |
+
"trend": 0,
|
| 126 |
+
"is_demo": false
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"level_cm": 368,
|
| 130 |
+
"timestamp": "2025-10-27T19:05:00+01:00",
|
| 131 |
+
"trend": 0,
|
| 132 |
+
"is_demo": false
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"level_cm": 368,
|
| 136 |
+
"timestamp": "2025-10-27T19:10:00+01:00",
|
| 137 |
+
"trend": 0,
|
| 138 |
+
"is_demo": false
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
"level_cm": 368,
|
| 142 |
+
"timestamp": "2025-10-27T19:15:00+01:00",
|
| 143 |
+
"trend": 0,
|
| 144 |
+
"is_demo": false
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"level_cm": 368,
|
| 148 |
+
"timestamp": "2025-10-27T19:20:00+01:00",
|
| 149 |
+
"trend": 0,
|
| 150 |
+
"is_demo": false
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"level_cm": 368,
|
| 154 |
+
"timestamp": "2025-10-27T19:25:00+01:00",
|
| 155 |
+
"trend": 0,
|
| 156 |
+
"is_demo": false
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"level_cm": 368,
|
| 160 |
+
"timestamp": "2025-10-27T19:30:00+01:00",
|
| 161 |
+
"trend": 0,
|
| 162 |
+
"is_demo": false
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"level_cm": 368,
|
| 166 |
+
"timestamp": "2025-10-27T19:35:00+01:00",
|
| 167 |
+
"trend": 0,
|
| 168 |
+
"is_demo": false
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"level_cm": 368,
|
| 172 |
+
"timestamp": "2025-10-27T19:40:00+01:00",
|
| 173 |
+
"trend": 0,
|
| 174 |
+
"is_demo": false
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"level_cm": 368,
|
| 178 |
+
"timestamp": "2025-10-27T19:45:00+01:00",
|
| 179 |
+
"trend": 0,
|
| 180 |
+
"is_demo": false
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"level_cm": 369,
|
| 184 |
+
"timestamp": "2025-10-27T19:50:00+01:00",
|
| 185 |
+
"trend": 0,
|
| 186 |
+
"is_demo": false
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
"level_cm": 369,
|
| 190 |
+
"timestamp": "2025-10-27T19:55:00+01:00",
|
| 191 |
+
"trend": 0,
|
| 192 |
+
"is_demo": false
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"level_cm": 369,
|
| 196 |
+
"timestamp": "2025-10-27T20:00:00+01:00",
|
| 197 |
+
"trend": 0,
|
| 198 |
+
"is_demo": false
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"level_cm": 368,
|
| 202 |
+
"timestamp": "2025-10-27T20:05:00+01:00",
|
| 203 |
+
"trend": 0,
|
| 204 |
+
"is_demo": false
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"level_cm": 368,
|
| 208 |
+
"timestamp": "2025-10-27T20:10:00+01:00",
|
| 209 |
+
"trend": 0,
|
| 210 |
+
"is_demo": false
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"level_cm": 368,
|
| 214 |
+
"timestamp": "2025-10-27T20:15:00+01:00",
|
| 215 |
+
"trend": 0,
|
| 216 |
+
"is_demo": false
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"level_cm": 369,
|
| 220 |
+
"timestamp": "2025-10-27T20:20:00+01:00",
|
| 221 |
+
"trend": 0,
|
| 222 |
+
"is_demo": false
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"level_cm": 369,
|
| 226 |
+
"timestamp": "2025-10-27T20:25:00+01:00",
|
| 227 |
+
"trend": 0,
|
| 228 |
+
"is_demo": false
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"level_cm": 369,
|
| 232 |
+
"timestamp": "2025-10-27T20:30:00+01:00",
|
| 233 |
+
"trend": 0,
|
| 234 |
+
"is_demo": false
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"level_cm": 368,
|
| 238 |
+
"timestamp": "2025-10-27T20:35:00+01:00",
|
| 239 |
+
"trend": 0,
|
| 240 |
+
"is_demo": false
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"level_cm": 368,
|
| 244 |
+
"timestamp": "2025-10-27T20:40:00+01:00",
|
| 245 |
+
"trend": 0,
|
| 246 |
+
"is_demo": false
|
| 247 |
+
},
|
| 248 |
+
{
|
| 249 |
+
"level_cm": 368,
|
| 250 |
+
"timestamp": "2025-10-27T20:45:00+01:00",
|
| 251 |
+
"trend": 0,
|
| 252 |
+
"is_demo": false
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"level_cm": 369,
|
| 256 |
+
"timestamp": "2025-10-27T20:50:00+01:00",
|
| 257 |
+
"trend": 0,
|
| 258 |
+
"is_demo": false
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"level_cm": 369,
|
| 262 |
+
"timestamp": "2025-10-27T20:55:00+01:00",
|
| 263 |
+
"trend": 0,
|
| 264 |
+
"is_demo": false
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"level_cm": 369,
|
| 268 |
+
"timestamp": "2025-10-27T21:00:00+01:00",
|
| 269 |
+
"trend": 0,
|
| 270 |
+
"is_demo": false
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"level_cm": 368,
|
| 274 |
+
"timestamp": "2025-10-27T21:05:00+01:00",
|
| 275 |
+
"trend": 0,
|
| 276 |
+
"is_demo": false
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"level_cm": 368,
|
| 280 |
+
"timestamp": "2025-10-27T21:10:00+01:00",
|
| 281 |
+
"trend": 0,
|
| 282 |
+
"is_demo": false
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
"level_cm": 368,
|
| 286 |
+
"timestamp": "2025-10-27T21:15:00+01:00",
|
| 287 |
+
"trend": 0,
|
| 288 |
+
"is_demo": false
|
| 289 |
+
}
|
| 290 |
+
]
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.9"
|
| 2 |
+
services:
|
| 3 |
+
rheinpegel:
|
| 4 |
+
build: .
|
| 5 |
+
image: rheinpegel-app:latest
|
| 6 |
+
ports:
|
| 7 |
+
- "${PORT:-8000}:8000"
|
| 8 |
+
environment:
|
| 9 |
+
SOURCE_URL: "https://www.stadt-koeln.de/interne-dienste/hochwasser/pegel_ws.php"
|
| 10 |
+
REFRESH_SECONDS: "120"
|
| 11 |
+
TZ: "Europe/Berlin"
|
| 12 |
+
PORT: "8000"
|
| 13 |
+
volumes:
|
| 14 |
+
- ./data:/app/data
|
| 15 |
+
restart: unless-stopped
|
pyproject.toml
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=65", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "rheinpegel-app"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "FastAPI app showing current Rhine water levels with caching and modern UI"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
authors = [{ name = "Rheinpegel Team" }]
|
| 11 |
+
license = { text = "MIT" }
|
| 12 |
+
requires-python = ">=3.11"
|
| 13 |
+
dependencies = [
|
| 14 |
+
"fastapi>=0.111",
|
| 15 |
+
"uvicorn[standard]>=0.29",
|
| 16 |
+
"httpx>=0.27",
|
| 17 |
+
"python-dotenv>=1.0",
|
| 18 |
+
"jinja2>=3.1",
|
| 19 |
+
"pydantic>=2.7",
|
| 20 |
+
"pydantic-settings>=2.4",
|
| 21 |
+
"prometheus-client>=0.20",
|
| 22 |
+
"python-dateutil>=2.9",
|
| 23 |
+
"defusedxml>=0.7"
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
[project.optional-dependencies]
|
| 27 |
+
dev = [
|
| 28 |
+
"pytest>=8.2",
|
| 29 |
+
"pytest-asyncio>=0.23",
|
| 30 |
+
"httpx>=0.27",
|
| 31 |
+
"ruff>=0.5.6",
|
| 32 |
+
"black>=24.4"
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
[tool.setuptools]
|
| 36 |
+
package-dir = {"" = "."}
|
| 37 |
+
include-package-data = true
|
| 38 |
+
|
| 39 |
+
[tool.setuptools.packages.find]
|
| 40 |
+
where = ["."]
|
| 41 |
+
include = ["app*"]
|
| 42 |
+
|
| 43 |
+
[tool.setuptools.package-data]
|
| 44 |
+
"app" = ["templates/*.j2", "static/*"]
|
| 45 |
+
|
| 46 |
+
[tool.black]
|
| 47 |
+
line-length = 88
|
| 48 |
+
target-version = ["py311"]
|
| 49 |
+
|
| 50 |
+
[tool.ruff]
|
| 51 |
+
line-length = 88
|
| 52 |
+
target-version = "py311"
|
| 53 |
+
select = ["E", "F", "B", "I", "UP", "ASYNC", "S", "N"]
|
| 54 |
+
ignore = ["S101"]
|
| 55 |
+
|
| 56 |
+
type-checking-mode = "basic"
|
| 57 |
+
|
| 58 |
+
[tool.ruff.lint.isort]
|
| 59 |
+
known-first-party = ["app"]
|
| 60 |
+
|
| 61 |
+
[tool.pytest.ini_options]
|
| 62 |
+
asyncio_mode = "auto"
|
| 63 |
+
addopts = "-q"
|
rheinpegel_app.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: rheinpegel-app
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: FastAPI app showing current Rhine water levels with caching and modern UI
|
| 5 |
+
Author: Rheinpegel Team
|
| 6 |
+
License: MIT
|
| 7 |
+
Requires-Python: >=3.11
|
| 8 |
+
Description-Content-Type: text/markdown
|
| 9 |
+
Requires-Dist: fastapi>=0.111
|
| 10 |
+
Requires-Dist: uvicorn[standard]>=0.29
|
| 11 |
+
Requires-Dist: httpx>=0.27
|
| 12 |
+
Requires-Dist: python-dotenv>=1.0
|
| 13 |
+
Requires-Dist: jinja2>=3.1
|
| 14 |
+
Requires-Dist: pydantic>=2.7
|
| 15 |
+
Requires-Dist: pydantic-settings>=2.4
|
| 16 |
+
Requires-Dist: prometheus-client>=0.20
|
| 17 |
+
Requires-Dist: python-dateutil>=2.9
|
| 18 |
+
Requires-Dist: defusedxml>=0.7
|
| 19 |
+
Provides-Extra: dev
|
| 20 |
+
Requires-Dist: pytest>=8.2; extra == "dev"
|
| 21 |
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
| 22 |
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
| 23 |
+
Requires-Dist: ruff>=0.5.6; extra == "dev"
|
| 24 |
+
Requires-Dist: black>=24.4; extra == "dev"
|
| 25 |
+
|
| 26 |
+
# Rheinpegel App
|
| 27 |
+
|
| 28 |
+
Eine moderne FastAPI-Anwendung, die den aktuellen Rheinpegel (Köln) abruft, zwischenspeichert und visuell aufbereitet. Die Anwendung kombiniert serverseitiges Polling mit einem reaktiven Frontend (AJAX, Countdown, Sparkline) und liefert zusätzlich Status- sowie Prometheus-Kennzahlen.
|
| 29 |
+
|
| 30 |
+
## Hauptfunktionen
|
| 31 |
+
|
| 32 |
+
- **Asynchrones Polling** der offiziellen Quelle via `httpx` mit Timeout (8 s) und exponentiellem Backoff (4 Versuche).
|
| 33 |
+
- **Automatische Format-Erkennung** für JSON oder XML und robuste Feldzuordnung.
|
| 34 |
+
- **Trend-Berechnung** aus Rohdaten oder anhand der letzten Messwerte (Δ > 2 cm ⇒ ↑, Δ < −2 ⇒ ↓).
|
| 35 |
+
- **Mehrstufiges Caching**: In-Memory-Ringpuffer (48 Werte) + JSON-Persistenz unter `data/cache.json`.
|
| 36 |
+
- **Demo-Modus** bei Netzwerkfehlern oder via `?demo=1` mit synthetischen, plausiblen Messwerten.
|
| 37 |
+
- **FastAPI + Jinja2** Dashboard mit responsive Karte, Warnstufen, Sparkline (Inline-SVG) sowie Auto-Refresh-Toggle und Countdown.
|
| 38 |
+
- **Prometheus-Metriken**: letzte Latenz, Erfolgs-/Fehlerzähler, Datenalter und Health-Endpoint.
|
| 39 |
+
|
| 40 |
+
## Voraussetzungen
|
| 41 |
+
|
| 42 |
+
- Python 3.11+
|
| 43 |
+
- Optional: [uv](https://github.com/astral-sh/uv) für schnelle virtuelle Umgebungen
|
| 44 |
+
|
| 45 |
+
## Installation & Setup
|
| 46 |
+
|
| 47 |
+
### Variante A: uv
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
uv venv
|
| 51 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 52 |
+
uv pip install -e .
|
| 53 |
+
cp .env.example .env
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Variante B: pip / venv
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
python -m venv .venv
|
| 60 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 61 |
+
pip install --upgrade pip
|
| 62 |
+
pip install -e .
|
| 63 |
+
copy .env.example .env # Windows
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
> **Hinweis:** Die Umgebungsvariablen (.env) steuern u.a. den Polling-Intervall (`REFRESH_SECONDS`), die Quelle (`SOURCE_URL`) und die Zeitzone (`TZ`).
|
| 67 |
+
|
| 68 |
+
## Starten der Anwendung
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
uvicorn app.main:app --reload
|
| 72 |
+
# oder
|
| 73 |
+
python -m app.main
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
Die Oberfläche ist anschließend unter http://127.0.0.1:8000 erreichbar.
|
| 77 |
+
|
| 78 |
+
## Wichtige Endpunkte
|
| 79 |
+
|
| 80 |
+
| Route | Beschreibung |
|
| 81 |
+
|-----------------|-------------------------------------------------|
|
| 82 |
+
| `/` | Dashboard mit Live-Karte, Trend, Sparkline |
|
| 83 |
+
| `/api/latest` | Neueste Messung (JSON) + komplette Historie |
|
| 84 |
+
| `/api/history` | Maximal 48 Messpunkte aus dem Ringpuffer |
|
| 85 |
+
| `/healthz` | 200 OK, wenn der Hintergrund-Task aktiv ist |
|
| 86 |
+
| `/metrics` | Prometheus-kompatible Kennzahlen |
|
| 87 |
+
|
| 88 |
+
### Demo-Modus
|
| 89 |
+
|
| 90 |
+
Alle Endpunkte akzeptieren `?demo=1`, um synthetische Daten zu liefern und das UI als „Demo“ zu markieren.
|
| 91 |
+
|
| 92 |
+
## Tests
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
pytest
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Die Tests decken das Parsing (JSON/XML) sowie zentrale FastAPI-Endpunkte ab.
|
| 99 |
+
|
| 100 |
+
## Docker
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
docker build -t rheinpegel-app .
|
| 104 |
+
docker run -p 8000:8000 --env-file .env rheinpegel-app
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
Oder via Compose:
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
docker-compose up --build
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
Der Container läuft als Non-Root-User, besitzt einen Healthcheck (`/healthz`) und bindet `./data` für persistente Caches ein.
|
| 114 |
+
|
| 115 |
+
## Beispiel systemd Unit (optional)
|
| 116 |
+
|
| 117 |
+
```ini
|
| 118 |
+
[Unit]
|
| 119 |
+
Description=Rheinpegel App
|
| 120 |
+
After=network.target
|
| 121 |
+
|
| 122 |
+
[Service]
|
| 123 |
+
User=www-data
|
| 124 |
+
WorkingDirectory=/opt/rheinpegel-app
|
| 125 |
+
EnvironmentFile=/opt/rheinpegel-app/.env
|
| 126 |
+
ExecStart=/opt/rheinpegel-app/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
| 127 |
+
Restart=always
|
| 128 |
+
|
| 129 |
+
[Install]
|
| 130 |
+
WantedBy=multi-user.target
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
## Weiterentwicklung
|
| 134 |
+
|
| 135 |
+
- UI-Optimierungen, z. B. zusätzliche Karten oder historische Auswertungen
|
| 136 |
+
- Export in weitere Formate (CSV/ICS)
|
| 137 |
+
- Alarm-Benachrichtigungen (E-Mail, Webhooks) basierend auf Warnstufen
|
| 138 |
+
|
| 139 |
+
Viel Spaß beim Ausprobieren der Rheinpegel-App!
|
rheinpegel_app.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
./app/__init__.py
|
| 4 |
+
./app/cache.py
|
| 5 |
+
./app/config.py
|
| 6 |
+
./app/fetcher.py
|
| 7 |
+
./app/main.py
|
| 8 |
+
./app/metrics.py
|
| 9 |
+
./app/models.py
|
| 10 |
+
./app/static/app.js
|
| 11 |
+
./app/static/styles.css
|
| 12 |
+
./app/templates/index.html.j2
|
| 13 |
+
rheinpegel_app.egg-info/PKG-INFO
|
| 14 |
+
rheinpegel_app.egg-info/SOURCES.txt
|
| 15 |
+
rheinpegel_app.egg-info/dependency_links.txt
|
| 16 |
+
rheinpegel_app.egg-info/requires.txt
|
| 17 |
+
rheinpegel_app.egg-info/top_level.txt
|
| 18 |
+
tests/test_endpoints.py
|
| 19 |
+
tests/test_parser.py
|
rheinpegel_app.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
rheinpegel_app.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111
|
| 2 |
+
uvicorn[standard]>=0.29
|
| 3 |
+
httpx>=0.27
|
| 4 |
+
python-dotenv>=1.0
|
| 5 |
+
jinja2>=3.1
|
| 6 |
+
pydantic>=2.7
|
| 7 |
+
pydantic-settings>=2.4
|
| 8 |
+
prometheus-client>=0.20
|
| 9 |
+
python-dateutil>=2.9
|
| 10 |
+
defusedxml>=0.7
|
| 11 |
+
|
| 12 |
+
[dev]
|
| 13 |
+
pytest>=8.2
|
| 14 |
+
pytest-asyncio>=0.23
|
| 15 |
+
httpx>=0.27
|
| 16 |
+
ruff>=0.5.6
|
| 17 |
+
black>=24.4
|
rheinpegel_app.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
app
|
tests/__pycache__/test_endpoints.cpython-313-pytest-8.4.1.pyc
ADDED
|
Binary file (8.97 kB). View file
|
|
|
tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc
ADDED
|
Binary file (8.64 kB). View file
|
|
|
tests/test_endpoints.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from zoneinfo import ZoneInfo
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from httpx import ASGITransport, AsyncClient
|
| 8 |
+
|
| 9 |
+
from app import main
|
| 10 |
+
from app.fetcher import FetchResult
|
| 11 |
+
from app.models import Measurement, Trend
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FakeService:
|
| 15 |
+
def __init__(self) -> None:
|
| 16 |
+
self._tz = ZoneInfo("Europe/Berlin")
|
| 17 |
+
self._history: list[Measurement] = [
|
| 18 |
+
Measurement(level_cm=400, timestamp=datetime.now(self._tz) - timedelta(minutes=10), trend=Trend.STABLE)
|
| 19 |
+
]
|
| 20 |
+
self.refresh_seconds = 120
|
| 21 |
+
|
| 22 |
+
async def start(self) -> None: # pragma: no cover - noop for tests
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
async def stop(self) -> None: # pragma: no cover - noop for tests
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
def is_running(self) -> bool:
|
| 29 |
+
return True
|
| 30 |
+
|
| 31 |
+
async def fetch_and_update(self, *, force_demo: bool = False) -> FetchResult:
|
| 32 |
+
base = self._history[-1].level_cm
|
| 33 |
+
level = base + (3 if force_demo else 1)
|
| 34 |
+
measurement = Measurement(
|
| 35 |
+
level_cm=level,
|
| 36 |
+
timestamp=datetime.now(self._tz),
|
| 37 |
+
trend=Trend.RISING if level > base else Trend.STABLE,
|
| 38 |
+
is_demo=force_demo,
|
| 39 |
+
)
|
| 40 |
+
self._history.append(measurement)
|
| 41 |
+
return FetchResult(measurement=measurement, latency_ms=42.0, is_demo=force_demo)
|
| 42 |
+
|
| 43 |
+
async def latest(self) -> Measurement | None:
|
| 44 |
+
return self._history[-1]
|
| 45 |
+
|
| 46 |
+
async def history(self) -> list[Measurement]:
|
| 47 |
+
return list(self._history[-5:])
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.fixture
|
| 51 |
+
async def test_client(monkeypatch) -> AsyncClient:
|
| 52 |
+
fake_service = FakeService()
|
| 53 |
+
monkeypatch.setattr(main, "service", fake_service)
|
| 54 |
+
main.app.dependency_overrides[main.get_service] = lambda: fake_service
|
| 55 |
+
transport = ASGITransport(app=main.app)
|
| 56 |
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
| 57 |
+
yield client
|
| 58 |
+
main.app.dependency_overrides.clear()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.mark.asyncio
|
| 62 |
+
async def test_api_latest_returns_measurement(test_client: AsyncClient) -> None:
|
| 63 |
+
response = await test_client.get("/api/latest")
|
| 64 |
+
assert response.status_code == 200
|
| 65 |
+
payload = response.json()
|
| 66 |
+
assert payload["measurement"]["level_cm"] >= 401
|
| 67 |
+
assert payload["history"]
|
| 68 |
+
assert payload["latency_ms"] == 42.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.mark.asyncio
|
| 72 |
+
async def test_api_history_returns_entries(test_client: AsyncClient) -> None:
|
| 73 |
+
response = await test_client.get("/api/history")
|
| 74 |
+
assert response.status_code == 200
|
| 75 |
+
payload = response.json()
|
| 76 |
+
assert "data" in payload
|
| 77 |
+
assert len(payload["data"]) <= 5
|
tests/test_parser.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from zoneinfo import ZoneInfo
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from app.config import Settings
|
| 7 |
+
from app.fetcher import _build_measurement_from_payload, _build_measurement_from_xml
|
| 8 |
+
from app.models import Measurement, Trend
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def settings() -> Settings:
|
| 13 |
+
return Settings(
|
| 14 |
+
source_url="https://example.com",
|
| 15 |
+
refresh_seconds=120,
|
| 16 |
+
tz="Europe/Berlin",
|
| 17 |
+
port=8000,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_parse_json_payload(settings: Settings) -> None:
|
| 22 |
+
previous = Measurement(
|
| 23 |
+
level_cm=350,
|
| 24 |
+
timestamp=datetime(2024, 1, 1, 11, 0, tzinfo=ZoneInfo("Europe/Berlin")),
|
| 25 |
+
trend=Trend.STABLE,
|
| 26 |
+
)
|
| 27 |
+
payload = {
|
| 28 |
+
"level": 356.4,
|
| 29 |
+
"timestamp": "2024-01-01T10:00:00Z",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
measurement = _build_measurement_from_payload(payload, settings, previous)
|
| 33 |
+
|
| 34 |
+
assert measurement.level_cm == 356
|
| 35 |
+
assert measurement.trend == Trend.RISING
|
| 36 |
+
assert measurement.timestamp.tzinfo is not None
|
| 37 |
+
assert measurement.timestamp.tzinfo.key == "Europe/Berlin"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_parse_xml_payload(settings: Settings) -> None:
|
| 41 |
+
previous = Measurement(
|
| 42 |
+
level_cm=410,
|
| 43 |
+
timestamp=datetime(2024, 1, 1, 12, 0, tzinfo=ZoneInfo("Europe/Berlin")),
|
| 44 |
+
trend=Trend.STABLE,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
xml = """
|
| 48 |
+
<root>
|
| 49 |
+
<Wasserstand>404.2</Wasserstand>
|
| 50 |
+
<Messzeit>2024-01-01T10:00:00Z</Messzeit>
|
| 51 |
+
</root>
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
measurement = _build_measurement_from_xml(xml, settings, previous)
|
| 55 |
+
|
| 56 |
+
assert measurement.level_cm == 404
|
| 57 |
+
assert measurement.trend == Trend.FALLING
|
| 58 |
+
assert measurement.timestamp.astimezone(ZoneInfo("Europe/Berlin")).hour == 11
|