Files
Northern-Thailand-Ping-Rive…/scripts/dev_proxy.py
T
grabowski cf10f2e4c8
CI / Format & lint (push) Successful in 26s
CI / Test suite (push) Successful in 1m15s
Docs / Validate documentation (push) Successful in 12s
feat(dashboard): console phase 4 - 2024 replay, simulation, replaces the old page
The console is now the dashboard at / (src/static/dashboard.html); the old
page and console.html are gone.

- Replay of the October 2024 flood (flood-2024-all.json): the page's clock,
  verdict, list, markers, flood zones and panel follow the replay; the panel
  charts the season on fixed axes with the 2024 model's 24 h peak. Hourly
  while the model alerts or P.1 is above 3.50 m, faster through quiet days;
  pause and drag. Rain, basin stations and the 5-minute refresh are off
  during the replay (today's data).
- ?demo_level= and ?demo_rise= simulate P.1. A violet REPLAY / amber
  SIMULATION bar, the top bar's KPI and the P.1 panel say so; the mode is
  drawn from state.mode alone, so a refresh or language switch cannot
  relabel either as live.
- History: rows without discharge_percent are ThaiWater's copy of the gauge
  (night hours RID publishes late), with a different rating (P.1: 26 against
  RID's 54 m3/s); their discharge is not drawn for gauges with RID discharge.
  The discharge strip has headroom so its label stays clear of the line.
- Swap: the old page's title, description, Open Graph and JSON-LD tags; no
  noindex; tests/test_dashboard.py = console tests + the old checks that
  still apply (one P.1 level, SEO files) + a replay/simulation labelling test.
- Docs: README screenshot (docs/img/dashboard.jpg, replacing two unused
  images), CLAUDE.md, CONTRIBUTING.md, FLOOD_FORECASTING.md (where the model
  shows), DASHBOARD_REDESIGN.md (phase 4 done, mode rules).

Deploy needs a restart: the server reads dashboard.html at start.
2026-10-20 13:47:15 +07:00

91 lines
3.6 KiB
Python

"""Serve the working-copy dashboard locally with API calls proxied to the
live server, so browser-side changes can be checked against real data
before deploy. Usage: python scripts/dev_proxy.py [port]
/ is src/static/dashboard.html, /static/... any file in src/static (with HTTP
range requests, which the map's PMTiles basemap needs), everything else the
live server."""
import http.server
import mimetypes
import os
import re
import sys
import urllib.request
from pathlib import Path
UPSTREAM = "https://water.buildfor.life"
STATIC = Path(__file__).resolve().parents[1] / "src" / "static"
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path.startswith("/?"):
body = (STATIC / "dashboard.html").read_bytes()
self._send(200, "text/html; charset=utf-8", body)
return
# Local overrides for endpoints not yet deployed: DEV_PROXY_LOCAL=/api/x=file.json,...
for pair in filter(None, os.environ.get("DEV_PROXY_LOCAL", "").split(",")):
prefix, file = pair.split("=", 1)
if self.path.split("?")[0] == prefix:
self._send(200, "application/json", Path(file).read_bytes())
return
if self.path.startswith("/static/"):
f = STATIC / self.path[len("/static/") :].split("?")[0]
if f.is_file():
ctype = (
"application/json"
if f.suffix in (".json", ".geojson")
else mimetypes.guess_type(f.name)[0] or "application/octet-stream"
)
if ctype.startswith("text/"):
ctype += "; charset=utf-8"
body = f.read_bytes()
# Byte ranges, like the server's ServeDir: PMTiles reads the
# basemap in pieces
m = re.fullmatch(r"bytes=(\d+)-(\d*)", self.headers.get("Range", ""))
if m:
start = int(m.group(1))
end = min(int(m.group(2) or len(body) - 1), len(body) - 1)
extra = {"Content-Range": f"bytes {start}-{end}/{len(body)}"}
self._send(206, ctype, body[start : end + 1], extra)
return
self._send(200, ctype, body)
return
try:
req = urllib.request.Request(
UPSTREAM + self.path,
headers={
"User-Agent": "Mozilla/5.0 (dev_proxy; +https://buildfor.life)",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=60) as r:
self._send(
r.status,
r.headers.get("Content-Type", "application/json"),
r.read(),
)
except urllib.error.HTTPError as e:
self._send(e.code, "application/json", e.read())
def _send(self, code, ctype, body, extra=None):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Accept-Ranges", "bytes")
for name, value in (extra or {}).items():
self.send_header(name, value)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
print(f"http://localhost:{port}/ (API -> {UPSTREAM})")
http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()