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.
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""The dashboard served at / (src/static/dashboard.html, docs/DASHBOARD_REDESIGN.md).
|
|
|
|
Rules: every fetched path is routed, every string has EN and TH (Thai first),
|
|
no emoji in the interface, and a replay or a simulation is never shown as
|
|
live data.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
STATIC = Path(__file__).parents[1] / "src" / "static"
|
|
DASHBOARD = STATIC / "dashboard.html"
|
|
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
|
|
|
|
|
|
def _html() -> str:
|
|
return DASHBOARD.read_text(encoding="utf-8")
|
|
|
|
|
|
def _body(html: str) -> str:
|
|
"""Markup only. The STRINGS table repeats every English phrase, so a
|
|
whole-file search would pass even if an element were deleted."""
|
|
return html[html.index("<body>") : html.index("<script src=")]
|
|
|
|
|
|
def _routes() -> set:
|
|
"""Paths the website serves (ping-collector's axum router)."""
|
|
return set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
|
|
|
|
|
|
def _tables(html: str) -> dict:
|
|
"""The `en:` / `th:` key sets of the STRINGS literal. Crude, but it
|
|
catches a key added to one language and forgotten in the other, which
|
|
silently falls back to English for Thai readers."""
|
|
start = html.index("const STRINGS = {")
|
|
end = html.index("// Thai unless the visitor's browser", start)
|
|
block = html[start:end]
|
|
tables = {}
|
|
for lang in ("en", "th"):
|
|
section = re.search(rf"\n {lang}: {{\n(.*?)\n }},\n", block, re.S)
|
|
assert section, f"{lang} table not found in STRINGS"
|
|
tables[lang] = set(re.findall(r"^\s{12}'([^']+)':", section.group(1), re.M))
|
|
return tables
|
|
|
|
|
|
def test_every_fetched_path_is_routed():
|
|
routes = _routes()
|
|
patterns = [
|
|
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
|
|
for r in routes
|
|
]
|
|
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", _html()))
|
|
assert {"/stations", "/forecast", "/api/notifications"} <= fetched
|
|
missing = [
|
|
p
|
|
for p in fetched
|
|
if not p.startswith("/static/")
|
|
and not any(r.match(p.rstrip("/") or "/") for r in patterns)
|
|
and not any(r.startswith(p) for r in routes)
|
|
]
|
|
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
|
|
|
|
|
|
def test_static_files_it_loads_exist():
|
|
paths = re.findall(r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html())
|
|
assert "ping-river-network.geojson" in paths # mapped rivers, not lines
|
|
assert "flood-2024-all.json" in paths
|
|
for path in paths:
|
|
assert (STATIC / path).is_file(), path
|
|
|
|
|
|
def test_translations_cover_both_languages():
|
|
tables = _tables(_html())
|
|
assert len(tables["en"]) > 100
|
|
assert not tables["en"] - tables["th"], sorted(tables["en"] - tables["th"])
|
|
assert not tables["th"] - tables["en"], sorted(tables["th"] - tables["en"])
|
|
|
|
|
|
def test_markup_keys_exist():
|
|
html = _html()
|
|
used = set(re.findall(r'data-i18n(?:-placeholder|-title|-aria)?="([^"]+)"', html))
|
|
assert not used - _tables(html)["en"], sorted(used - _tables(html)["en"])
|
|
|
|
|
|
def test_aria_labels_are_translated():
|
|
"""A Thai page must not hand screen-reader users English landmarks."""
|
|
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', _body(_html())):
|
|
assert "data-i18n-aria" in match.group(0), match.group(0)[:120]
|
|
|
|
|
|
def test_no_emoji_in_the_interface():
|
|
emoji = re.compile("[\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff\ufe0f]")
|
|
found = sorted(set(emoji.findall(_html())))
|
|
assert not found, f"emoji in dashboard.html: {found}"
|
|
|
|
|
|
def test_thai_first_and_river_time():
|
|
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai."""
|
|
html = _html()
|
|
assert "if (code.startsWith('th')) return 'th';" in html
|
|
assert "ping-monitor-lang" in html # remembered language choice
|
|
assert "const TZ = 'Asia/Bangkok';" in html
|
|
assert "'+07:00'" in html # naive API timestamps are pinned to Bangkok
|
|
|
|
|
|
def test_phone_safe():
|
|
html = _html()
|
|
assert "overflow-x: hidden" in html
|
|
assert 'name="viewport"' in html
|
|
|
|
|
|
def test_fonts_are_self_hosted_with_their_licence():
|
|
html = _html()
|
|
assert "fonts.googleapis.com" not in html
|
|
for name in ("inter-latin.woff2", "noto-sans-thai-thai.woff2"):
|
|
assert f"/static/fonts/{name}" in html
|
|
assert (STATIC / "fonts" / "inter-OFL.txt").is_file()
|
|
assert (STATIC / "fonts" / "noto-sans-thai-OFL.txt").is_file()
|
|
|
|
|
|
def test_basemap_is_self_hosted():
|
|
html = _html()
|
|
assert "/static/basemap/ping-basin.pmtiles" in html
|
|
assert "protomaps-leaflet@5.1.0" in html and 'integrity="sha384-' in html
|
|
# the attribution OpenStreetMap's licence requires
|
|
assert "openstreetmap.org/copyright" in html
|
|
|
|
|
|
def test_basin_stations_and_rain_have_their_feeds():
|
|
html = _html()
|
|
assert "fetch('/api/hii/waterlevel/latest')" in html
|
|
assert "fetch('/sensors/thaiwater')" in html # fallback feed
|
|
assert "fetch('/api/hii/rainfall/latest')" in html
|
|
|
|
|
|
def test_history_reaches_today():
|
|
"""/measurements/history returns the oldest rows first and stops at its
|
|
limit (50,000 by default): "All" must ask for more, or P.1 ends in 2024."""
|
|
html = _html()
|
|
assert "hours=${hours}&limit=100000" in html
|
|
assert "/api/forecast/history/" in html
|
|
|
|
|
|
def test_skill_loads_only_when_opened():
|
|
html = _html()
|
|
assert "fetch('/api/forecast/skill?station_code=P.1&horizon=24')" in html
|
|
assert "sk.addEventListener('toggle'" in html
|
|
|
|
|
|
def test_one_current_river_level():
|
|
"""The verdict and the P.1 outlook must not disagree about "now".
|
|
|
|
The old dashboard once showed 1.66 m in the banner and 1.52 m in the
|
|
outlook: the outlook used the forecast payload's current_level from an
|
|
older as_of. Every view reads the gauge's level from one station record.
|
|
"""
|
|
html = _html()
|
|
assert "current_level" not in html
|
|
assert "const p1 = state.byCode.get('P.1');" in html
|
|
|
|
|
|
def test_replay_and_simulation_are_never_shown_as_live():
|
|
"""Relabelling a replay or a pinned simulation as live would present
|
|
2024 or invented flood data as today's."""
|
|
html = _html()
|
|
# the mode bar and the data-age KPI are drawn from state.mode alone, and
|
|
# redrawn on a language switch
|
|
assert "bar.hidden = state.mode === 'live';" in html
|
|
assert "if (newest && state.mode === 'live') {" in html
|
|
assert "renderStats();\n renderModebar();\n }" in html
|
|
# the 5-minute refresh would replace the replay frame with today's data
|
|
assert "if (state.mode !== 'replay') { load(); loadStats(); }" in html
|
|
# the demo hooks
|
|
assert "params.get('demo_level')" in html and "params.has('demo_rise')" in html
|
|
for key in ("mode.replay", "mode.sim", "sim.pinned", "sim.rising"):
|
|
assert f"'{key}':" in html
|
|
|
|
|
|
def test_seo_and_indexing_files():
|
|
html = _html()
|
|
assert '<meta name="description"' in html
|
|
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
|
|
assert 'property="og:title"' in html
|
|
assert "application/ld+json" in html
|
|
assert 'rel="icon"' in html
|
|
assert "noindex" not in html
|
|
|
|
robots = (STATIC / "robots.txt").read_text(encoding="utf-8")
|
|
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
|
|
sitemap = (STATIC / "sitemap.xml").read_text(encoding="utf-8")
|
|
assert "<loc>https://water.buildfor.life/</loc>" in sitemap
|
|
llms = (STATIC / "llms.txt").read_text(encoding="utf-8")
|
|
assert "Ping River Live Monitor" in llms
|
|
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _routes()
|