feat(dashboard): the console, phase 1 of the redesign (preview at /static/console.html)
The user chose direction B of the redesign: a full-screen operations console.
It is built next to the current dashboard, served by ServeDir at
/static/console.html (noindex, links back to /), and replaces dashboard.html
once it covers every feature (docs/DASHBOARD_REDESIGN.md, phase 4).
Phase 1:
- top bar: the Chiang Mai verdict (P.1 level + 24 h forecast, the current
dashboard's rule), gauges reporting, highest 24 h flood chance, data age
("overnight pause" instead of "stale" during RID's nightly gap, as in the
notifier), EN/TH, light/dark (dark default), Get alerts
- gauge list: one status rule for list, map and panel (normal, watch within
0.5 m or >= 25 % chance, warning, danger, no data), margin to the warning
level, 72 h sparkline, 6 h change; filter; sort by river order, closest to
the warning level, rising; arrow keys
- map: Ping mainstem and tributaries, status markers with code + level,
layers Rain 24 h (on by default only with heavy rain) and Flood zones
(auto-shown while P.1 is at a stage), one legend
- station panel: level, change, discharge (last RID value when the newest
hour is level-only), warning/danger levels with margins, capacity, level
scale with now and 24 h peak, 72 h chart with the forecast peaks, forecast
table with the model and issue time, the P.1 inundation stages, data links
- alerts drawer with the ntfy topics; selection in the URL (#P.84); refresh
every 5 minutes
- Inter and Noto Sans Thai self-hosted (src/static/fonts, 75 KB, OFL), no
emoji, Thai first, Bangkok time
tests/test_console.py: routed fetch paths, EN/TH parity, translated aria
labels, no emoji, fonts and licences, noindex. dev_proxy.py serves
src/static with real content types (it sent HTML as octet-stream).
This commit is contained in:
1 parent
0e30ddbcb6
commit
301a629b42
11 files changed
+1839
-3
No files matched your search
@@ -0,0 +1,100 @@
|
||||
"""The console dashboard (src/static/console.html, docs/DASHBOARD_REDESIGN.md).
|
||||
|
||||
Served at /static/console.html while it is built; it replaces dashboard.html
|
||||
once it covers every feature. The rules are the dashboard's: every fetched
|
||||
path is routed, every string has EN and TH, no emoji in the interface.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
STATIC = Path(__file__).parents[1] / "src" / "static"
|
||||
CONSOLE = STATIC / "console.html"
|
||||
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return CONSOLE.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _body(html: str) -> str:
|
||||
return html[html.index("<body>") : html.index("<script src=")]
|
||||
|
||||
|
||||
def _tables(html: str) -> dict:
|
||||
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 = set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
|
||||
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"console fetches paths the server does not route: {missing}"
|
||||
|
||||
|
||||
def test_static_files_it_loads_exist():
|
||||
for path in re.findall(r"/static/([\w./-]+\.(?:geojson|json|woff2))", _html()):
|
||||
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():
|
||||
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 console.html: {found}"
|
||||
|
||||
|
||||
def test_thai_first_and_river_time():
|
||||
html = _html()
|
||||
assert "if (code.startsWith('th')) return 'th';" in html
|
||||
assert "const TZ = 'Asia/Bangkok';" in html
|
||||
assert "'+07:00'" in html # naive API timestamps are pinned to Bangkok
|
||||
|
||||
|
||||
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_preview_is_not_indexed():
|
||||
assert '<meta name="robots" content="noindex">' in _html()
|
||||
Reference in new issue
Block a user