Archaeo commited on
Commit
b12fc58
·
verified ·
1 Parent(s): e7dbb79

Upload 37 files

Browse files
.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&nbsp;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