feat(dashboard): console phase 4 - 2024 replay, simulation, replaces the old page
CI / Format & lint (push) Successful in 26s
CI / Test suite (push) Successful in 1m15s
Docs / Validate documentation (push) Successful in 12s

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.
This commit is contained in:
grabowski committed 2026-10-20 13:47:15 +07:00
1 parent ffd3030352
commit cf10f2e4c8
15 files changed
+2487 -4667

No files matched your search

+6 -6
View File
@@ -30,12 +30,12 @@ plus the monthly `water-monitor-retrain.timer` (Python, `.venv`, uv-managed). Up
- No `git add -A`: zero-byte shell-accident files (`#`, `$(wc`, ...) have been committed - No `git add -A`: zero-byte shell-accident files (`#`, `$(wc`, ...) have been committed
before. Stage files by name. before. Stage files by name.
- Do not add Co-Authored-By trailers. - Do not add Co-Authored-By trailers.
- The dashboard is a single file, `src/static/dashboard.html`, EN + TH via the `t()` - The dashboard is a single file, `src/static/dashboard.html` (design and rules in
table: every user-visible string needs both languages. Every path it fetches must be docs/DASHBOARD_REDESIGN.md), EN + TH via the `t()` table: every user-visible string
a route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks). Its needs both languages, Thai is the default, no emoji. Every path it fetches must be a
replacement, the console (`src/static/console.html`, preview at /static/console.html, route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks). A replay or
plan in docs/DASHBOARD_REDESIGN.md), follows the same rules plus no emoji a simulation must never be shown as live data. Preview it against production with
(tests/test_console.py); preview it with `uv run python scripts/dev_proxy.py`. `uv run python scripts/dev_proxy.py`; the server reads it at start (restart).
- Rust (`rust/`, see rust/README.md): `cargo fmt --all`, `cargo clippy --all-targets - Rust (`rust/`, see rust/README.md): `cargo fmt --all`, `cargo clippy --all-targets
-- -D warnings`, `cargo test`; minimum version 1.88 -- -D warnings`, `cargo test`; minimum version 1.88
(`CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked`). Read (`CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked`). Read
+2 -1
View File
@@ -29,7 +29,8 @@ Without `TEST_DATABASE_URL` the database tests are skipped, the rest still run.
- One logical change per commit, imperative subject (`fix(collector): ...`, - One logical change per commit, imperative subject (`fix(collector): ...`,
`feat(web): ...`, `docs: ...`). `feat(web): ...`, `docs: ...`).
- Dashboard changes (`src/static/dashboard.html`): every visible string in English - Dashboard changes (`src/static/dashboard.html`): every visible string in English
and Thai (`t()` table); a screenshot in the merge request. and Thai (`t()` table), no emoji; screenshots (light and dark, phone and desktop)
in the merge request. Preview with `uv run python scripts/dev_proxy.py`.
- API changes: keep existing fields and formats; external clients read them. New - API changes: keep existing fields and formats; external clients read them. New
parameters go into the route's declaration in `rust/collector/src/web/params.rs`, parameters go into the route's declaration in `rust/collector/src/web/params.rs`,
which also documents them at `/docs`. which also documents them at `/docs`.
+8 -4
View File
@@ -11,6 +11,8 @@ a gauge crosses its warning or danger level.
after the [October 2024 flood](https://buildfor.life/blog/chiang-mai-flood-2024/) — after the [October 2024 flood](https://buildfor.life/blog/chiang-mai-flood-2024/) —
background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https://buildfor.life/blog/ping-river-monitor/). background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https://buildfor.life/blog/ping-river-monitor/).
![The dashboard: Chiang Mai's flood verdict and the gauges in the top bar, the gauge list, the map and the panel for P.1 Nawarat Bridge with its history and forecast](docs/img/dashboard.jpg)
[![CI](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/ci.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [![CI](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/ci.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Rust](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/rust.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [![Rust](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/rust.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Security](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/security.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [![Security](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/security.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
@@ -35,9 +37,11 @@ background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https
data through August 2024, the first alert came **13 hours before** P.1 crossed data through August 2024, the first alert came **13 hours before** P.1 crossed
3.70 m. Everything about the model, including what did not work: 3.70 m. Everything about the model, including what did not work:
[docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md). [docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md).
- **Shows it.** A Leaflet map with the river drawn as OSM geometry and styled by live - **Shows it.** One screen: Chiang Mai's flood verdict, the gauges with their status
discharge, rain gauges, the Chiang Mai inundation zones, per-station history, the and trend, a self-hosted map of the basin with rain and the city's inundation
forecast card, a replay of the 2024 flood, English/Thai, light/dark. zones, and a panel per station with its history since 2018, the forecast and how
accurate it has been. ThaiWater's basin stations, a replay of the 2024 flood,
Thai/English, dark/light.
- **Notifies.** Public push alerts over a self-hosted [ntfy](https://ntfy.sh): one - **Notifies.** Public push alerts over a self-hosted [ntfy](https://ntfy.sh): one
message when a gauge crosses its warning or danger level, one all-clear on the message when a gauge crosses its warning or danger level, one all-clear on the
way down, an opt-in early-warning topic from the model, nothing in between. way down, an opt-in early-warning topic from the model, nothing in between.
@@ -176,7 +180,7 @@ models/ trained bundles (gitignored) and evaluation results
- [docs/MATRIX_QUICK_START.md](docs/MATRIX_QUICK_START.md) — the same alerts in a Matrix room - [docs/MATRIX_QUICK_START.md](docs/MATRIX_QUICK_START.md) — the same alerts in a Matrix room
- [rust/README.md](rust/README.md) — the service: commands, configuration, develop, deploy - [rust/README.md](rust/README.md) — the service: commands, configuration, develop, deploy
- [docs/RUST_REWRITE.md](docs/RUST_REWRITE.md) — why and how it moved from Python to Rust - [docs/RUST_REWRITE.md](docs/RUST_REWRITE.md) — why and how it moved from Python to Rust
- [docs/DASHBOARD_REDESIGN.md](docs/DASHBOARD_REDESIGN.md) — the new console dashboard (preview at `/static/console.html`): layout, rules, phases - [docs/DASHBOARD_REDESIGN.md](docs/DASHBOARD_REDESIGN.md) — the dashboard: layout, rules, how it replaced the old page
- [docs/references/NOTABLE_DOCUMENTS.md](docs/references/NOTABLE_DOCUMENTS.md) — official Thai government resources - [docs/references/NOTABLE_DOCUMENTS.md](docs/references/NOTABLE_DOCUMENTS.md) — official Thai government resources
- Public overview: [buildfor.life/docs/tooling/ping-river-monitor](https://buildfor.life/docs/tooling/ping-river-monitor/) - Public overview: [buildfor.life/docs/tooling/ping-river-monitor](https://buildfor.life/docs/tooling/ping-river-monitor/)
+20 -18
View File
@@ -1,19 +1,19 @@
# Dashboard redesign: the console # Dashboard redesign: the console
Status: **phase 3 done** (2026-10-19). The new dashboard is built next to the current Status: **phase 4 done** (2026-10-20). The console is the dashboard: it replaced the
one and replaces it in phase 4. old page at `/` (`src/static/dashboard.html`; the server reads it at start, so a
change needs a restart). Phase 5 (polish) is open.
- **Preview:** <https://water.buildfor.life/static/console.html>, after `git pull` on the - **Locally:** `uv run python scripts/dev_proxy.py 8765`, then <http://localhost:8765/>.
server (static files are read per request, no restart). It is not indexed The API is proxied to production.
(`noindex`) and links back to the current dashboard. - **Rules** (`tests/test_dashboard.py`):
- **Locally:** `uv run python scripts/dev_proxy.py 8765`, then
<http://localhost:8765/static/console.html>. The API is proxied to production.
- **Rules:** the same as for `dashboard.html`, plus no emoji (`tests/test_console.py`):
- every path it fetches is a Rust route; - every path it fetches is a Rust route;
- every string exists in EN and TH, and Thai is the default; - every string exists in EN and TH, and Thai is the default;
- timestamps are Bangkok wall-clock time. - no emoji;
- timestamps are Bangkok wall-clock time;
- a replay or a simulation is never shown as live data.
## Why: the current page ## Why: the old page
Observed on 2026-10-19 at 1440 px and on a 390 px phone. Observed on 2026-10-19 at 1440 px and on a 390 px phone.
@@ -69,7 +69,7 @@ One screen, no page scroll on a desktop:
## Where every current element goes ## Where every current element goes
| Current (`dashboard.html` id) | Console | Phase | | Old page (`dashboard.html` id) | Console | Phase |
|---|---|---| |---|---|---|
| `flood-verdict`, `verdict-*` | Verdict in the top bar | 1 | | `flood-verdict`, `verdict-*` | Verdict in the top bar | 1 |
| `station-count`, `last-updated`, `data-age`, `live-pill` | Top-bar KPIs (reporting, data age) | 1 | | `station-count`, `last-updated`, `data-age`, `live-pill` | Top-bar KPIs (reporting, data age) | 1 |
@@ -91,7 +91,7 @@ One screen, no page scroll on a desktop:
## Design rules ## Design rules
- **Type:** Inter (Latin) and Noto Sans Thai, self-hosted in `src/static/fonts/` (75 KB, OFL). Numbers use tabular figures. - **Type:** Inter (Latin) and Noto Sans Thai, self-hosted in `src/static/fonts/` (75 KB, OFL). Numbers use tabular figures.
- **Colours:** CSS variables for light and dark, with dark the default; a saved choice wins (`ping-river-theme`, shared with the current dashboard). - **Colours:** CSS variables for light and dark, with dark the default; a saved choice wins (`ping-river-theme`, the old dashboard's key, so the choice carried over).
- **Accent:** `#1d6fd6` light, `#4c9bf0` dark. - **Accent:** `#1d6fd6` light, `#4c9bf0` dark.
- **Status:** colours are used only for status, each with an `-ink` variant for text (WCAG AA on white). - **Status:** colours are used only for status, each with an `-ink` variant for text (WCAG AA on white).
- **One status rule** (`statusOf`) for the map, list and panel. It is display-only; the notifier keeps its own transitions. - **One status rule** (`statusOf`) for the map, list and panel. It is display-only; the notifier keeps its own transitions.
@@ -104,14 +104,16 @@ One screen, no page scroll on a desktop:
| Danger | level ≥ `threshold_danger` | | Danger | level ≥ `threshold_danger` |
| No data | no reading for 24 h | | No data | no reading for 24 h |
- **City verdict:** the current dashboard's rule — flooding from P.1 ≥ 3.70 m; "likely" at danger ≥ 50 % or warning ≥ 70 %; watch at warning ≥ 20 % or level ≥ 3.20 m. - **City verdict:** the old dashboard's rule — flooding from P.1 ≥ 3.70 m; "likely" at danger ≥ 50 % or warning ≥ 70 %; watch at warning ≥ 20 % or level ≥ 3.20 m.
- **Data age:** "feed stale" after 3 h, except during RID's overnight pause (21:00 to 09:00 with an evening reading). The pause shows as "overnight pause", the same rule as the notifier's `FEED_NIGHT_UNTIL_HOUR`. - **Data age:** "feed stale" after 3 h, except during RID's overnight pause (21:00 to 09:00 with an evening reading). The pause shows as "overnight pause", the same rule as the notifier's `FEED_NIGHT_UNTIL_HOUR`.
- **Discharge:** comes with RID's report. When the newest hour is a level-only reading, the panel shows the last RID discharge with its time ("at 14:00"). - **Discharge:** comes with RID's report. When the newest hour is a level-only reading, the panel shows the last RID discharge with its time ("at 14:00").
- **Rain layer:** off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered. - **Rain layer:** off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered.
- **Flood zones:** shown automatically while P.1 is at or above a stage, unless hidden by the user. - **Flood zones:** shown automatically while P.1 is at or above a stage, unless hidden by the user.
- **Basin stations:** ThaiWater's classes for the level as a share of the bank height (critically low ≤ 10 %, low ≤ 30 %, normal ≤ 70 %, high ≤ 100 %, over the bank). On the map and in the list over the bank is danger, high is watch, the rest normal; no data after 24 h. Gauges RID already lists are shown once, as RID gauges. The layer is off until the list is opened or the button pressed. - **Basin stations:** ThaiWater's classes for the level as a share of the bank height (critically low ≤ 10 %, low ≤ 30 %, normal ≤ 70 %, high ≤ 100 %, over the bank). On the map and in the list over the bank is danger, high is watch, the rest normal; no data after 24 h. Gauges RID already lists are shown once, as RID gauges. The layer is off until the list is opened or the button pressed.
- **History:** above 2,500 readings (30 d and longer for most gauges) the chart shows the daily mean with the daily low–high band, so a flood peak is not averaged away; gaps longer than 3.5 h (2.5 days when daily) break the line rather than bridging RID's nightly pause. Short ranges reload with every refresh, long ones at most hourly. "All" asks for `limit=100000`: `/measurements/history` returns the oldest rows first and stops at its 50,000 default, which made the current dashboard's "All time" for P.1 end in October 2024 (fixed there too). - **History:** above 2,500 readings (30 d and longer for most gauges) the chart shows the daily mean with the daily low–high band, so a flood peak is not averaged away; gaps longer than 3.5 h (2.5 days when daily) break the line rather than bridging RID's nightly pause. Short ranges reload with every refresh, long ones at most hourly. "All" asks for `limit=100000`: `/measurements/history` returns the oldest rows first and stops at its 50,000 default, which made the old dashboard's "All time" for P.1 end in October 2024.
- **Forecast accuracy:** loaded when the section is opened (a cold `/api/forecast/skill` query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip. - **Forecast accuracy:** loaded when the section is opened (a cold `/api/forecast/skill` query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip.
- **Replay of October 2024:** `src/static/flood-2024-all.json` (hourly readings of the 16 gauges, 15 September – 12 October 2024, and the 24 h warning chance and peak of a model trained on data before 1 September 2024). The page's clock, verdict, list, markers, flood zones and panel follow the replay; the panel's chart shows the season on fixed axes up to the replay hour. It plays hour by hour while the model alerts or P.1 is above 3.50 m, faster through quiet days, and can be paused and dragged. Rain, the basin stations and the 5-minute refresh are off during the replay, since they are today's data. A violet bar (REPLAY, date, P.1 level, the model's message) and the top bar's "Replay time" replace the live data age.
- **Simulation:** `?demo_level=4.4` pins P.1 at 4.40 m; `?demo_rise=1` raises it by 5 cm every 0.7 s from the real level to the 2024 record (5.30 m). An amber SIMULATION bar names the real level; the top bar and the P.1 panel say "simulated". Only P.1 is simulated: the forecasts and other gauges stay live.
- **Basemap:** `src/static/basemap/ping-basin.pmtiles`, a Protomaps extract (bbox 97.85–99.95 E, 16.85–20.2 N, zoom 0–12, 15 MB, OpenStreetMap data), drawn by protomaps-leaflet 5.1.0 in its `light`/`dark` style with `name:th`/`name:en` labels. Refresh instructions in `src/static/basemap/README.md`. The map cannot be panned far outside the basin. OSM raster tiles are the fallback if the renderer does not load. - **Basemap:** `src/static/basemap/ping-basin.pmtiles`, a Protomaps extract (bbox 97.85–99.95 E, 16.85–20.2 N, zoom 0–12, 15 MB, OpenStreetMap data), drawn by protomaps-leaflet 5.1.0 in its `light`/`dark` style with `name:th`/`name:en` labels. Refresh instructions in `src/static/basemap/README.md`. The map cannot be panned far outside the basin. OSM raster tiles are the fallback if the renderer does not load.
## Phases ## Phases
@@ -124,7 +126,7 @@ Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light
- Map with status markers, rain and flood-zone layers. - Map with status markers, rain and flood-zone layers.
- Station panel (readings, scale, 72 h chart, forecast table, P.1 stages). - Station panel (readings, scale, 72 h chart, forecast table, P.1 stages).
- Alerts drawer (ntfy topics); URL selection; 5-minute refresh. - Alerts drawer (ntfy topics); URL selection; 5-minute refresh.
- `tests/test_console.py`; `dev_proxy.py` serves `src/static` with proper content types. - `tests/test_console.py` (now `tests/test_dashboard.py`); `dev_proxy.py` serves `src/static` with proper content types.
2. **Done — Map and basin.** 2. **Done — Map and basin.**
- **Basemap:** the self-hosted Protomaps extract replaces the dimmed OSM raster tiles, whose baked-in labels were the noisiest part of the screen. Labels follow the page language; light and dark styles. - **Basemap:** the self-hosted Protomaps extract replaces the dimmed OSM raster tiles, whose baked-in labels were the noisiest part of the screen. Labels follow the page language; light and dark styles.
- **Server:** ServeDir answers HTTP range requests (checked: 206); `dev_proxy.py` now does too. - **Server:** ServeDir answers HTTP range requests (checked: 206); `dev_proxy.py` now does too.
@@ -134,10 +136,10 @@ Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light
- **History in the panel** (inline SVG, no chart library): ranges and dates, level and discharge, issued 24 h forecasts overlaid, warning/danger bands, hover/touch values. - **History in the panel** (inline SVG, no chart library): ranges and dates, level and discharge, issued 24 h forecasts overlaid, warning/danger bands, hover/touch values.
- **P.1 forecast accuracy** as a collapsible panel section, from `/api/forecast/skill`. - **P.1 forecast accuracy** as a collapsible panel section, from `/api/forecast/skill`.
- **Data statistics:** a status-bar popover, from `/api/stats`. - **Data statistics:** a status-bar popover, from `/api/stats`.
4. **Modes and the swap.** 4. **Done — Modes and the swap.**
- **October 2024 replay:** banner, clock, markers and verdict follow the replay; zones auto-show. - **October 2024 replay:** banner, clock, markers and verdict follow the replay; zones auto-show.
- **Demo hooks:** `?demo_level=`, `?demo_rise=`. A mode pill (LIVE / REPLAY / SIMULATION) never relabels a replay or simulation as live. - **Demo hooks:** `?demo_level=`, `?demo_rise=`. A mode pill (LIVE / REPLAY / SIMULATION) never relabels a replay or simulation as live.
- **The swap:** `console.html` becomes `dashboard.html`; the old file and its tests go. `tests/test_console.py` absorbs the remaining checks of `tests/test_dashboard.py` (one P.1 level, SEO tags; the robots `noindex` is removed). - **The swap:** `console.html` became `dashboard.html`, with the old page's title, description, Open Graph and JSON-LD tags; the robots `noindex` is gone. `tests/test_dashboard.py` is the console's tests plus the old ones that still apply (one P.1 level, SEO and indexing files).
- **Docs:** README screenshot and the CLAUDE.md rule. - **Docs:** README screenshot and the CLAUDE.md rule.
5. **Polish.** 5. **Polish.**
- Phone layout pass. - Phone layout pass.
@@ -149,5 +151,5 @@ Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light
## Data notes from building phase 1 ## Data notes from building phase 1
- **Negative levels.** P.4A (−1.28 m) and P.67 (−0.59 m) report negative levels: gauge datums, not errors. The panel's scale starts at the lowest of 0 and the station's 72 h readings. - **Negative levels.** P.4A (−1.28 m) and P.67 (−0.59 m) report negative levels: gauge datums, not errors. The panel's scale starts at the lowest of 0 and the station's 72 h readings.
- **P.1 discharge disagrees between endpoints.** At 15:00 on 2026-10-19, `/measurements/latest` had level 1.30 m with no discharge, while `/measurements/history` had 26.0 m³/s with no capacity. RID's 14:00 report said 53.9 m³/s. The console shows the last RID discharge. Worth checking where the 26.0 comes from. - **P.1 discharge disagrees between endpoints.** At 15:00 on 2026-10-19, `/measurements/latest` had level 1.30 m with no discharge, while `/measurements/history` had 26.0 m³/s with no capacity. RID's 14:00 report said 53.9 m³/s. The rows without capacity (`discharge_percent`) are ThaiWater's copy of the gauge, which fills hours RID has not published yet (its night hours); their discharge comes from a different rating, about half of RID's at P.1. The panel shows the last RID discharge, and the history chart drops the discharge of those rows for gauges RID reports discharge for.
- **P.84 spike.** A one-hour spike at 13:00 the same day: 0.73 → 1.05 → 0.78 m. - **P.84 spike.** A one-hour spike at 13:00 the same day: 0.73 → 1.05 → 0.78 m.
+18 -11
View File
@@ -639,17 +639,24 @@ and `scripts/install.sh` does that on every install.
### Dashboard ### Dashboard
The "Flood risk outlook" panel (`src/static/dashboard.html`, `loadForecasts()`) The dashboard (`src/static/dashboard.html`) loads `/forecast` with the gauges; if
loads non-blocking after the map renders and **stays hidden unless `/forecast` it fails or is empty, the page works without forecasts. Where the model shows:
returns a non-empty array** — a non-OK response, an empty array, or a thrown
fetch all just leave the panel hidden, and the rest of the dashboard is - **Top bar.** Chiang Mai's verdict from P.1: flooding at ≥ 3.70 m; "likely" at a
unaffected. Per the note above, this means the panel appears with heuristic-only 24 h danger chance ≥ 50 % or warning chance ≥ 70 %; "watch" at a warning chance
content once data is flowing but before any model is trained; the per-chip ≥ 20 % or a level ≥ 3.20 m. Next to it, the highest 24 h warning chance of any gauge.
tooltip is what tells you so. Stations are sorted worst-risk first, each showing - **Gauge list and map.** A gauge is "watch" from a 24 h warning chance of 25 %.
three chips (6/12/24 h) coloured by risk band, with the tooltip carrying the exact - **Station panel.** Per horizon (6/12/24 h) the predicted peak and the warning and
warning and danger percentages, the predicted peak level, and a "heuristic danger chances, the model version and when it was trained, or "Simple estimate" when
fallback" note when the row did not come from a model. The panel is labelled the row came from the heuristic fallback. For P.1 the chance of reaching each of the
*experimental*. seven inundation stages. The history chart overlays the 24 h peaks as they were
issued (`/api/forecast/history`), and "How accurate is the forecast?" loads
`/api/forecast/skill`.
- **Replay.** "Replay the October 2024 flood" plays `src/static/flood-2024-all.json`:
the gauge readings of 2024 with what a model trained only on data before
1 September 2024 said at each hour.
The page labels the forecast experimental.
### Training on the server ### Training on the server
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+3 -2
View File
@@ -2,8 +2,9 @@
live server, so browser-side changes can be checked against real data live server, so browser-side changes can be checked against real data
before deploy. Usage: python scripts/dev_proxy.py [port] before deploy. Usage: python scripts/dev_proxy.py [port]
/ is src/static/dashboard.html, /static/... any file in src/static (e.g. the / is src/static/dashboard.html, /static/... any file in src/static (with HTTP
console preview at /static/console.html), everything else the live server.""" range requests, which the map's PMTiles basemap needs), everything else the
live server."""
import http.server import http.server
import mimetypes import mimetypes
+1 -1
View File
@@ -1,6 +1,6 @@
# Basemap # Basemap
`ping-basin.pmtiles` is the map under the console (`console.html`): a `ping-basin.pmtiles` is the map under the dashboard (`dashboard.html`): a
[Protomaps](https://protomaps.com) vector extract of the Ping basin, zoom 0-12 [Protomaps](https://protomaps.com) vector extract of the Ping basin, zoom 0-12
(drawn larger beyond that), 15 MB. The site serves it like any static file; (drawn larger beyond that), 15 MB. The site serves it like any static file;
the browser reads it in pieces with HTTP range requests (ServeDir answers them, the browser reads it in pieces with HTTP range requests (ServeDir answers them,
File diff suppressed because it is too large. Load diff
+2279 -2186
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,4 +10,4 @@ SIL Open Font License 1.1 (`*-OFL.txt` next to them).
Both files are Google Fonts' woff2 subsets (`fonts.googleapis.com/css2?family=Inter:wght@400..700` Both files are Google Fonts' woff2 subsets (`fonts.googleapis.com/css2?family=Inter:wght@400..700`
and `family=Noto+Sans+Thai:wght@400..700`, fetched with a woff2-capable user agent). and `family=Noto+Sans+Thai:wght@400..700`, fetched with a woff2-capable user agent).
The `unicode-range` of each `@font-face` in `console.html` matches its subset. The `unicode-range` of each `@font-face` in `dashboard.html` matches its subset.
-130
View File
@@ -1,130 +0,0 @@
"""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|pmtiles))", _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_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_have_a_fallback_feed():
html = _html()
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "fetch('/sensors/thaiwater')" 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."""
assert "hours=${hours}&limit=100000" in _html()
dashboard = (STATIC / "dashboard.html").read_text(encoding="utf-8")
assert "&limit=100000" in dashboard
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_preview_is_not_indexed():
assert '<meta name="robots" content="noindex">' in _html()
+149 -187
View File
@@ -1,47 +1,20 @@
"""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 import re
from pathlib import Path from pathlib import Path
DASHBOARD_PATH = Path(__file__).parents[1] / "src" / "static" / "dashboard.html" STATIC = Path(__file__).parents[1] / "src" / "static"
ROUTER_PATH = ( DASHBOARD = STATIC / "dashboard.html"
Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs" ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
)
def _rust_routes() -> set: def _html() -> str:
"""Paths the website serves (ping-collector's axum router).""" return DASHBOARD.read_text(encoding="utf-8")
source = ROUTER_PATH.read_text(encoding="utf-8")
return set(re.findall(r'\.route\(\s*"([^"]+)"', source))
def test_every_api_path_the_dashboard_fetches_is_served():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
routes = _rust_routes()
patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
for r in routes
]
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", html))
assert fetched, "no fetch() calls found"
missing = [
path
for path in fetched
if not path.startswith("/static/")
and not any(p.match(path.rstrip("/") or "/") for p in patterns)
and not any(r.startswith(path) for r in routes)
]
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
def test_dashboard_contains_live_map_and_flow_visualization():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert 'id="station-map"' in html
assert 'id="river-flow"' in html
assert "fetch('/stations')" in html or 'fetch("/stations")' in html
assert (
"fetch('/measurements/latest" in html or 'fetch("/measurements/latest' in html
)
assert "leaflet" in html.lower()
def _body(html: str) -> str: def _body(html: str) -> str:
@@ -50,92 +23,15 @@ def _body(html: str) -> str:
return html[html.index("<body>") : html.index("<script src=")] return html[html.index("<body>") : html.index("<script src=")]
def test_dashboard_explains_flow_legend_and_refresh(): def _routes() -> set:
body = _body(DASHBOARD_PATH.read_text(encoding="utf-8")) """Paths the website serves (ping-collector's axum router)."""
return set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
assert 'data-i18n="legend.flow"' in body
assert 'data-i18n="stat.updated"' in body
assert 'data-i18n="action.refresh"' in body
def test_dashboard_uses_mapped_river_network_instead_of_station_connections(): def _tables(html: str) -> dict:
html = DASHBOARD_PATH.read_text(encoding="utf-8") """The `en:` / `th:` key sets of the STRINGS literal. Crude, but it
river_network = DASHBOARD_PATH.with_name("ping-river-network.geojson") catches a key added to one language and forgotten in the other, which
silently falls back to English for Thai readers."""
assert river_network.exists()
assert "fetch('/static/ping-river-network.geojson')" in html
assert "mainBasin.map" not in html
def test_dashboard_loads_additional_thaiwater_sensors():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/sensors/thaiwater')" in html
assert 'data-i18n="sensors.title"' in _body(html)
assert 'id="station-search"' in html
assert "applyStationSearch" in html
def test_dashboard_has_seo_and_indexing_files():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
static_dir = DASHBOARD_PATH.parent
assert '<meta name="description"' in html
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
assert 'property="og:title"' in html
assert 'rel="icon"' in html
robots = (static_dir / "robots.txt").read_text(encoding="utf-8")
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
assert "<loc>https://water.buildfor.life/</loc>" in (
static_dir / "sitemap.xml"
).read_text(encoding="utf-8")
assert "Ping River Live Monitor" in (static_dir / "llms.txt").read_text(
encoding="utf-8"
)
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _rust_routes()
def test_dashboard_shows_hii_rainfall_layer():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/api/hii/rainfall/latest')" in html
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "renderRainLayer" in html
assert "rain-toggle" in html
body = _body(html)
assert 'data-i18n="legend.rain"' in body
# TMD rain classes on the legend
assert 'data-i18n="legend.rain.heavy"' in body
assert 'data-i18n="legend.rain.extreme"' in body
def test_dashboard_loads_station_history_chart():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert 'id="history-card"' in html
assert "/api/forecast/history/" in html
assert "'chart.model'" in html
assert "PostgreSQL" not in html
assert "/measurements/history/" in html
assert "history-chart" in html
# Date-range picker alongside the quick-range dropdown
assert 'id="history-start"' in html
assert 'id="history-end"' in html
assert 'data-i18n="range.7d"' in _body(html)
assert 'data-i18n="range.90d"' in _body(html)
def _extract_lang_tables(html: str) -> dict:
"""Pull the `en:` / `th:` key sets out of the STRINGS literal.
Parsing the JS with a regex is crude, but it is enough to catch the failure
that matters: a key added to one language and forgotten in the other, which
silently falls back to English for Thai readers.
"""
import re
start = html.index("const STRINGS = {") start = html.index("const STRINGS = {")
end = html.index("// Thai unless the visitor's browser", start) end = html.index("// Thai unless the visitor's browser", start)
block = html[start:end] block = html[start:end]
@@ -147,86 +43,152 @@ def _extract_lang_tables(html: str) -> dict:
return tables return tables
def test_dashboard_translations_cover_both_languages(): def test_every_fetched_path_is_routed():
html = DASHBOARD_PATH.read_text(encoding="utf-8") routes = _routes()
tables = _extract_lang_tables(html) patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
assert len(tables["en"]) > 100, "expected the full English string table" for r in routes
missing_th = tables["en"] - tables["th"] ]
missing_en = tables["th"] - tables["en"] fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", _html()))
assert not missing_th, f"keys missing a Thai translation: {sorted(missing_th)}" assert {"/stations", "/forecast", "/api/notifications"} <= fetched
assert ( missing = [
not missing_en p
), f"Thai-only keys with no English fallback: {sorted(missing_en)}" 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_dashboard_i18n_markup_keys_exist(): def test_static_files_it_loads_exist():
"""Every data-i18n attribute must resolve to a real string key.""" paths = re.findall(r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html())
import re 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
html = DASHBOARD_PATH.read_text(encoding="utf-8")
keys = _extract_lang_tables(html)["en"] 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)) used = set(re.findall(r'data-i18n(?:-placeholder|-title|-aria)?="([^"]+)"', html))
unknown = used - keys assert not used - _tables(html)["en"], sorted(used - _tables(html)["en"])
assert not unknown, f"markup references undefined string keys: {sorted(unknown)}"
def test_dashboard_is_mobile_portrait_safe(): def test_aria_labels_are_translated():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
# The header row overflowed a 412 px Android viewport by 64 px until it wrapped
assert "flex-wrap: wrap" in html
assert "overflow-x: hidden" in html
assert 'id="lang-toggle"' in html
assert "ping-monitor-lang" in html # remembered language choice
def test_dashboard_translates_user_visible_aria_labels():
"""A Thai page must not hand screen-reader users English landmarks.""" """A Thai page must not hand screen-reader users English landmarks."""
import re for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', _body(_html())):
assert "data-i18n-aria" in match.group(0), match.group(0)[:120]
html = DASHBOARD_PATH.read_text(encoding="utf-8")
body = _body(html)
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', body):
tag = match.group(0)
if "data-i18n-aria" in tag or 'id="lang-toggle"' in tag:
continue # the toggle sets its own label per language in JS
raise AssertionError(f"aria-label without a translation key: {tag[:120]}")
def test_dashboard_keeps_simulation_and_replay_labels_on_language_switch(): def test_no_emoji_in_the_interface():
"""Relabelling a pinned simulation as LIVE would present fake flood data as real.""" emoji = re.compile("[\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff\ufe0f]")
html = DASHBOARD_PATH.read_text(encoding="utf-8") found = sorted(set(emoji.findall(_html())))
assert not found, f"emoji in dashboard.html: {found}"
assert "setLiveIndicator(state.liveMode || 'live', state.liveLabelKey)" in html
assert "if (!state.replayTimer) setLiveIndicator('live');" not in html
def test_dashboard_default_language_respects_browser_order(): def test_thai_first_and_river_time():
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai, not English.""" """navigator.languages = ['th-TH','en-US'] must resolve to Thai."""
html = DASHBOARD_PATH.read_text(encoding="utf-8") html = _html()
assert "langs.some" not in html # the old any-English-wins test
assert "if (code.startsWith('th')) return 'th';" in 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_dashboard_shows_one_current_river_level(): def test_phone_safe():
"""The verdict banner and the P.1 outlook must not disagree about "now". html = _html()
assert "overflow-x: hidden" in html
assert 'name="viewport"' in html
Production served 1.66 m in the banner and 1.52 m in the outlook at the
same moment: the banner used the latest measurement, the outlook used the def test_fonts_are_self_hosted_with_their_licence():
forecast payload's current_level from an older as_of. 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 = DASHBOARD_PATH.read_text(encoding="utf-8") html = _html()
assert "current_level" not in html
assert "const p1 = state.byCode.get('P.1');" in html
# One arbitrated writer, freshest-wins
assert "function setP1Level(" in html
assert "state.p1NowAt" in html
# Neither feed may assign the level directly any more def test_replay_and_simulation_are_never_shown_as_live():
assert "state.p1Now = Number(p1.water_level)" not in html """Relabelling a replay or a pinned simulation as live would present
assert "state.p1Now = row.current_level" not in html 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
# The outlook renders the arbitrated level, and never a peak below it
assert "const shownNow = state.p1Now != null" in html def test_seo_and_indexing_files():
assert "Math.max(Number(row.predicted_max_level), shownNow)" in html 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()