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.
91 lines
3.6 KiB
Python
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()
|