feat(web): the website and API in ping-collector; Matrix gets the ntfy transitions
Phase 3 of docs/RUST_REWRITE.md: rust/collector/src/web (axum) serves what
the FastAPI app (src/web_api.py) served, so one process collects, forecasts,
notifies and serves. With WEB_LISTEN=0.0.0.0:8000 it runs inside
`ping-collector run`; `ping-collector serve` runs it alone.
Same routes, parameters, JSON fields and field order, status codes,
{"detail": ...} errors and pydantic-style 422 lists, Cache-Control, gzip
(>500 B), CORS, Umami API tracking, the dashboard and static files, and the
stale-while-revalidate response caches (now with byte budgets: a history
response can be 10 MB). /docs is the same Swagger UI (+ /redoc); its OpenAPI
document is built from the route table, whose per-route parameter
declarations also validate the requests, so docs and behaviour cannot drift.
Python output conventions are kept: isoformat(), round(), numpy's pairwise
sums (catchment means), Python's summation order (skill metrics).
scripts/api_parity.py compares the two servers route by route on one
database (53 cases incl. errors, admin and static files; query values vary
per run so FastAPI's caches answer fresh). Locally: 53 identical; the
station CRUD writes byte-identical stations.json files; the skill/trend
logic matches on 9 station/horizon pairs with synthetic model versions.
Intended differences: /health counts the ThaiWater telemetry of the same
gauges while RID lags (as the notifier); /scraping/status adds the collector
version and last forecast run; an old stored forecast is served marked
X-Data-Stale instead of recomputed; /sensors/thaiwater is cached a minute;
.geojson is served as application/geo+json. Load (local, 16 clients): the
dashboard's routes 1375 vs 552 req/s, uncached history 714 vs 53 req/s;
25 MB resident when serving only.
Matrix (MATRIX_*): the notifier sends the room the transitions ntfy gets
(per station, outlook, feed; not ntfy's per-topic summaries), each once,
with its own matrix: keys in notification_state, seeded from ntfy's on first
use so nothing already announced repeats. Checked against a fake homeserver.
It replaces the `run.py --alert-check` cron job (repeated alerts every 15
min), which install_collector.sh now comments out (crontab backup kept).
install_collector.sh: --check-api (ping-collector serve on :8001 next to
FastAPI, api_parity.py), --cutover-api (refuses on a difference; sets
WEB_LISTEN, stops and disables water-monitor.service), --rollback-api;
--rollback-forecast and --rollback bring the Python app back as server.
ping-collector.service: MemoryMax 512M. Config::from_vars for tests; route
tests against a throwaway schema.
This commit is contained in:
1 parent
1d1c000922
commit
779728e38b
32 files changed
+5678
-146
No files matched your search
+9
-1
@@ -116,11 +116,19 @@ PUBLIC_URL=https://water.buildfor.life/
|
|||||||
# telemetry is current (0 = never)
|
# telemetry is current (0 = never)
|
||||||
RID_STALE_ALERT_HOURS=18
|
RID_STALE_ALERT_HOURS=18
|
||||||
|
|
||||||
# Matrix Alerting Configuration
|
# Matrix: ping-collector sends the ntfy transitions to this room too
|
||||||
|
# (docs/MATRIX_QUICK_START.md); off while token or room is empty
|
||||||
MATRIX_HOMESERVER=https://matrix.org
|
MATRIX_HOMESERVER=https://matrix.org
|
||||||
MATRIX_ACCESS_TOKEN=
|
MATRIX_ACCESS_TOKEN=
|
||||||
MATRIX_ROOM_ID=
|
MATRIX_ROOM_ID=
|
||||||
|
|
||||||
|
# Website and API served by ping-collector (rust/collector/src/web): the
|
||||||
|
# address to listen on; empty = the Python app serves (port 8000). Set by
|
||||||
|
# scripts/install_collector.sh --cutover-api.
|
||||||
|
WEB_LISTEN=
|
||||||
|
# STATIC_DIR=src/static
|
||||||
|
# ADMIN_API_KEY= # X-API-Key for station editing, /config, /scrape/trigger
|
||||||
|
|
||||||
# Grafana Integration
|
# Grafana Integration
|
||||||
GRAFANA_URL=http://localhost:3000
|
GRAFANA_URL=http://localhost:3000
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ Production: one systemd unit on a small VPS, `/opt/thailand-water-monitor`, user
|
|||||||
notify constants: `uv run python -m src.ml.export --config` (a test fails
|
notify constants: `uv run python -m src.ml.export --config` (a test fails
|
||||||
otherwise) and rebuild the collector. Notify texts:
|
otherwise) and rebuild the collector. Notify texts:
|
||||||
`rust/collector/testdata/gen_notify_cases.py`.
|
`rust/collector/testdata/gen_notify_cases.py`.
|
||||||
|
- API routes live in `rust/collector/src/web` (phase 3). Until the FastAPI app is
|
||||||
|
removed, a route change goes into both, and `scripts/api_parity.py` (both
|
||||||
|
servers on one database) must report no differences. A route's parameters are
|
||||||
|
declared once (`web/params.rs`); that list validates and documents (/docs).
|
||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
@@ -51,7 +55,9 @@ Production: one systemd unit on a small VPS, `/opt/thailand-water-monitor`, user
|
|||||||
promote. `scripts/dev_proxy.py`: serve the working-copy dashboard against the live API.
|
promote. `scripts/dev_proxy.py`: serve the working-copy dashboard against the live API.
|
||||||
- `rust/collector` (`ping-collector`): Rust data collector; with it deployed the
|
- `rust/collector` (`ping-collector`): Rust data collector; with it deployed the
|
||||||
Python app runs `COLLECTION_ENABLED=false`. With `FORECAST_IN_COLLECTOR=true` it
|
Python app runs `COLLECTION_ENABLED=false`. With `FORECAST_IN_COLLECTOR=true` it
|
||||||
also forecasts (`rust/forecast`) and sends ntfy; the API serves its
|
also forecasts (`rust/forecast`) and sends ntfy + Matrix; with `WEB_LISTEN` it
|
||||||
`forecast_current`. Rewrite plan: `docs/RUST_REWRITE.md`.
|
serves the website and API (`rust/collector/src/web`, the port of
|
||||||
|
`src/web_api.py`; the dashboard stays `src/static/dashboard.html`). Rewrite
|
||||||
|
plan: `docs/RUST_REWRITE.md`.
|
||||||
- `docs/FLOOD_FORECASTING.md` is the authoritative model write-up; `docs/DATA_SOURCES.md`
|
- `docs/FLOOD_FORECASTING.md` is the authoritative model write-up; `docs/DATA_SOURCES.md`
|
||||||
the source catalog.
|
the source catalog.
|
||||||
+31
-33
@@ -41,45 +41,43 @@ MATRIX_ROOM_ID=!your_room_id:matrix.org
|
|||||||
GRAFANA_URL=http://localhost:3000
|
GRAFANA_URL=http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 5: Test Configuration
|
## Step 5: Restart and check
|
||||||
|
The Matrix messages are sent by `ping-collector` (the Rust service, with
|
||||||
|
`FORECAST_IN_COLLECTOR=true`), together with the ntfy notifications:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test Matrix connection
|
sudo systemctl restart ping-collector
|
||||||
uv run python run.py --alert-test
|
journalctl -u ping-collector | grep -i matrix # "Matrix notifications: room ... on ..."
|
||||||
|
# what would be sent right now (ntfy and Matrix), changing nothing:
|
||||||
# Check system status (shows Matrix config)
|
sudo -u water-monitor /opt/thailand-water-monitor/bin/ping-collector notify --dry-run
|
||||||
uv run python run.py --status
|
|
||||||
|
|
||||||
# Run alert check
|
|
||||||
uv run python run.py --alert-check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example Alert Message
|
## What the room receives
|
||||||
When thresholds are exceeded, you'll receive messages like:
|
The same transitions as the ntfy topics (docs/NOTIFICATIONS.md), each once:
|
||||||
|
a station crossing its warning or danger level and back (0.10 m hysteresis),
|
||||||
|
the P.1 early-warning outlook turning on and off, and the gauge feed going
|
||||||
|
stale or recovering. ntfy's per-topic summaries ("Ping River: warning level
|
||||||
|
reached") are not repeated in the room. For example:
|
||||||
|
|
||||||
```
|
```
|
||||||
🌊 **WATER LEVEL ALERT**
|
⚠️ **Warning level at P.1**
|
||||||
|
Nawarat Bridge, Chiang Mai city: 3.75 m at 18 Oct 14:00, above the warning level of 3.70 m.
|
||||||
|
|
||||||
**Station:** P.1 (สถานีเชียงใหม่)
|
📈 Dashboard: https://water.buildfor.life/
|
||||||
**Alert Type:** Critical Water Level
|
|
||||||
**Severity:** CRITICAL
|
|
||||||
|
|
||||||
**Current Level:** 6.75m
|
|
||||||
**Threshold:** 6.0m
|
|
||||||
**Difference:** +0.75m
|
|
||||||
**Discharge:** 450.2 cms
|
|
||||||
|
|
||||||
**Time:** 2025-09-26 14:30:00
|
|
||||||
|
|
||||||
📈 View dashboard: http://localhost:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cron Job Setup (Optional)
|
The room has its own delivery state (`matrix:` keys in `notification_state`),
|
||||||
Add to crontab for automatic alerting:
|
so a Matrix outage is retried on Matrix only and does not re-send ntfy.
|
||||||
```bash
|
|
||||||
# Check water levels every 15 minutes
|
The old `run.py --alert-check` cron job (every 15 minutes, repeating an alert
|
||||||
*/15 * * * * cd /path/to/monitor && uv run python run.py --alert-check >> alerts.log 2>&1
|
for as long as a level stayed high) is replaced by this;
|
||||||
```
|
`scripts/install_collector.sh` comments it out of the crontab once
|
||||||
|
ping-collector sends Matrix, keeping a backup next to the app.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- **403 Error**: Check Matrix access token is valid
|
- **HTTP 401/403** in `journalctl -u ping-collector`: the access token is invalid
|
||||||
- **Room Not Found**: Verify room ID includes `!` prefix and `:homeserver.com` suffix
|
or the account is not in the room.
|
||||||
- **No Alerts**: Check database has recent data with `uv run python run.py --status`
|
- **Room not found**: the room ID needs the `!` prefix and the `:homeserver`
|
||||||
|
suffix.
|
||||||
|
- **Nothing sent**: there was no transition; `ping-collector notify --dry-run`
|
||||||
|
shows the readings judged and what would be sent.
|
||||||
@@ -48,7 +48,9 @@ message then says "ThaiWater telemetry; the RID report is not in yet". RID's
|
|||||||
report regularly lags 2-10 h at night, which used to send "gauge feed stale"
|
report regularly lags 2-10 h at night, which used to send "gauge feed stale"
|
||||||
around midnight and "recovered" in the morning. Now the feed is stale only
|
around midnight and "recovered" in the morning. Now the feed is stale only
|
||||||
when neither source has a reading for 3 h, and RID alone lagging is reported
|
when neither source has a reading for 3 h, and RID alone lagging is reported
|
||||||
once after `RID_STALE_ALERT_HOURS` (18, 0 = off).
|
once after `RID_STALE_ALERT_HOURS` (18, 0 = off). With `MATRIX_*` set, the
|
||||||
|
same transitions also go to a Matrix room, each once (per station, outlook,
|
||||||
|
feed; not the per-topic summaries), with their own `matrix:` state keys.
|
||||||
|
|
||||||
With `FORECAST_IN_COLLECTOR=false`, `src/notify.py` is called once per collection cycle inside the API process
|
With `FORECAST_IN_COLLECTOR=false`, `src/notify.py` is called once per collection cycle inside the API process
|
||||||
(leader only), right after the forecast precompute, so it sees exactly the
|
(leader only), right after the forecast precompute, so it sees exactly the
|
||||||
@@ -136,8 +138,9 @@ template.
|
|||||||
|
|
||||||
## Why ntfy and not …
|
## Why ntfy and not …
|
||||||
|
|
||||||
- **Matrix** (`src/alerting.py`, still there): needs a homeserver account per
|
- **Matrix**: needs a homeserver account per subscriber and a room invite;
|
||||||
subscriber and a room invite; fine for a team, wrong for the public.
|
fine for a team, wrong for the public. The team room gets the same
|
||||||
|
transitions from ping-collector (docs/MATRIX_QUICK_START.md).
|
||||||
- **Gotify**: also self-hosted and light, but Android-only client and one
|
- **Gotify**: also self-hosted and light, but Android-only client and one
|
||||||
account per subscriber.
|
account per subscriber.
|
||||||
- **Email / SMS**: deliverability work, cost per message, no priority
|
- **Email / SMS**: deliverability work, cost per message, no priority
|
||||||
|
|||||||
+32
-9
@@ -1,7 +1,8 @@
|
|||||||
# A Rust rewrite: assessment
|
# A Rust rewrite: assessment
|
||||||
|
|
||||||
Status: **in progress.** Phase 1 (collector) is in production since 2026-10-17,
|
Status: **in progress.** Phase 1 (collector) is in production since 2026-10-17,
|
||||||
phase 2 (forecast + ntfy) since 2026-10-18; see
|
phase 2 (forecast + ntfy) since 2026-10-18; phase 3 (website/API + Matrix) is
|
||||||
|
built and parity-checked, cutover pending; see
|
||||||
[rust/README.md](../rust/README.md). Spike: [spikes/rust-forecast](../spikes/rust-forecast/README.md).
|
[rust/README.md](../rust/README.md). Spike: [spikes/rust-forecast](../spikes/rust-forecast/README.md).
|
||||||
|
|
||||||
## Short version
|
## Short version
|
||||||
@@ -85,7 +86,7 @@ This slows model iteration, which is the most active part of the repo.
|
|||||||
| 0 | Spike: P.1 features + HGB inference | done, bit-identical |
|
| 0 | Spike: P.1 features + HGB inference | done, bit-identical |
|
||||||
| 1 | `ping-collector`: RID, HII, dams, Open-Meteo | in production since 2026-10-17 23:09 (Python: `COLLECTION_ENABLED=false`) |
|
| 1 | `ping-collector`: RID, HII, dams, Open-Meteo | in production since 2026-10-17 23:09 (Python: `COLLECTION_ENABLED=false`) |
|
||||||
| 2 | Forecast crate + ntfy | in production since 2026-10-18 15:08 (`FORECAST_IN_COLLECTOR=true`); live check on production data: 48/48 rows identical, 96/96 probabilities bit-identical |
|
| 2 | Forecast crate + ntfy | in production since 2026-10-18 15:08 (`FORECAST_IN_COLLECTOR=true`); live check on production data: 48/48 rows identical, 96/96 probabilities bit-identical |
|
||||||
| 3 | Read API | |
|
| 3 | Website + read API in `ping-collector`, Matrix like ntfy | built 2026-10-18; locally 53 of 53 compared routes identical to FastAPI (`scripts/api_parity.py`); cutover with `install_collector.sh --cutover-api` |
|
||||||
| 4 | Cleanup | |
|
| 4 | Cleanup | |
|
||||||
|
|
||||||
0. **Spike: done.** P.1 features + HGB inference, bit-identical.
|
0. **Spike: done.** P.1 features + HGB inference, bit-identical.
|
||||||
@@ -106,14 +107,36 @@ This slows model iteration, which is the most active part of the repo.
|
|||||||
NaN at serving time (docs/FLOOD_FORECASTING.md section 5). The ntfy change:
|
NaN at serving time (docs/FLOOD_FORECASTING.md section 5). The ntfy change:
|
||||||
ThaiWater mirror readings keep level alerts and the feed check current
|
ThaiWater mirror readings keep level alerts and the feed check current
|
||||||
while RID lags at night. The Python API then only serves.
|
while RID lags at night. The Python API then only serves.
|
||||||
3. **Read API** (~2k LOC): routes recorded from the Python API as golden files,
|
3. **Website + read API** (`rust/collector/src/web`, axum): every FastAPI
|
||||||
then a Caddy switch. Python keeps only training/evaluation.
|
route with the same parameters, JSON fields and order, status codes,
|
||||||
4. **Cleanup:** drop the multi-backend adapters, demo databases and the
|
FastAPI-style errors (`{"detail": ...}`, pydantic's 422 list), cache
|
||||||
Python web stack.
|
headers, gzip, CORS, Umami API tracking and the stale-while-revalidate
|
||||||
|
response caches (now with byte budgets). `/docs` is the same Swagger UI;
|
||||||
|
its OpenAPI document is generated from the route table whose parameter
|
||||||
|
declarations also do the validation. One process serves, collects,
|
||||||
|
forecasts and notifies (`WEB_LISTEN`), so the status file, the leader
|
||||||
|
election and the `forecast_current` hand-off between processes are gone
|
||||||
|
in effect. Checked by `scripts/api_parity.py`, which runs `ping-collector
|
||||||
|
serve` next to the FastAPI app on the same database and compares every
|
||||||
|
route (query values vary per run so FastAPI's caches answer fresh);
|
||||||
|
`--cutover-api` refuses to switch on a difference. Intended differences:
|
||||||
|
`/health` counts ThaiWater telemetry of the same gauges while RID lags (as
|
||||||
|
the notifier); `/scraping/status` adds the collector version and the last
|
||||||
|
forecast run; `/forecast` serves an old stored forecast marked
|
||||||
|
`X-Data-Stale` instead of recomputing (same process); `/sensors/thaiwater`
|
||||||
|
is cached for a minute; database errors serve the last good response
|
||||||
|
where FastAPI answered `[]`. Matrix: the notifier's transitions go to the
|
||||||
|
room too (per station, outlook, feed; not the per-topic summaries), with
|
||||||
|
their own `matrix:` state keys seeded from ntfy's, replacing the
|
||||||
|
`run.py --alert-check` cron job that repeated alerts every 15 minutes.
|
||||||
|
Python keeps training, evaluation and the monthly retrain timer.
|
||||||
|
4. **Cleanup:** drop FastAPI/uvicorn, the Python collector, notifier, CLI and
|
||||||
|
the multi-backend adapters (SQLite, MySQL, InfluxDB, VictoriaMetrics) and
|
||||||
|
demo databases; the Python environment shrinks to the training stack.
|
||||||
|
|
||||||
Open questions before phase 3: is Matrix alerting still used? Must `/docs`
|
Decided before phase 3 (2026-10-18): Matrix stays and behaves like ntfy;
|
||||||
(OpenAPI UI) stay? Is SQLite dev mode needed? Where does the monthly retrain
|
`/docs` stays; SQLite dev mode goes; the monthly retrain stays on the same
|
||||||
run if the server stops having a Python environment for the API?
|
machine (Python, `water-monitor-retrain.timer`).
|
||||||
|
|
||||||
## Cheaper alternative (stay Python)
|
## Cheaper alternative (stay Python)
|
||||||
|
|
||||||
|
|||||||
Generated
+167
-1
@@ -88,6 +88,18 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.51"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -109,6 +121,56 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -237,6 +299,23 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||||
|
dependencies = [
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -443,6 +522,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -578,7 +663,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash 0.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -668,12 +753,24 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -686,6 +783,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -979,6 +1077,12 @@ dependencies = [
|
|||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -995,6 +1099,22 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -1197,9 +1317,11 @@ name = "ping-collector"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"form_urlencoded",
|
||||||
"log",
|
"log",
|
||||||
"ping-forecast",
|
"ping-forecast",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1207,6 +1329,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -1566,6 +1690,8 @@ version = "1.0.154"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
"indexmap",
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1573,6 +1699,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -2063,6 +2200,19 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -2084,12 +2234,22 @@ version = "0.6.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -2182,6 +2342,12 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|||||||
+8
-2
@@ -13,17 +13,23 @@ repository = "https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "query", "json", "matched-path", "original-uri"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
form_urlencoded = "1"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
# native-tls (OpenSSL on Linux, SChannel on Windows), not rustls: app.rid.go.th (dams)
|
# native-tls (OpenSSL on Linux, SChannel on Windows), not rustls: app.rid.go.th (dams)
|
||||||
# offers only TLS 1.2 CBC suites (ECDHE-RSA-AES256-SHA384), which rustls refuses.
|
# offers only TLS 1.2 CBC suites (ECDHE-RSA-AES256-SHA384), which rustls refuses.
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["native-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["native-tls", "json"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
# preserve_order: JSON objects keep the order they were built in (API
|
||||||
|
# responses list their fields in the same order as the FastAPI app did)
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip", "preserve_order"] }
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "macros"] }
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "macros"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal", "net", "sync", "fs"] }
|
||||||
|
tower = { version = "0.5", default-features = false, features = ["util"] }
|
||||||
|
tower-http = { version = "0.6", features = ["compression-gzip", "cors", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
|||||||
+40
-2
@@ -7,6 +7,7 @@ costs and the parity rules are in [docs/RUST_REWRITE.md](../docs/RUST_REWRITE.md
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `collector` (`ping-collector`) | RID, HII/ThaiWater, RID dams and Open-Meteo collection in the Python web app | phase 1: in production since 2026-10-17 |
|
| `collector` (`ping-collector`) | RID, HII/ThaiWater, RID dams and Open-Meteo collection in the Python web app | phase 1: in production since 2026-10-17 |
|
||||||
| `forecast` (`ping-forecast`, library) | the flood forecast (`src/ml` serving path); run by `ping-collector` with ntfy | phase 2: in production since 2026-10-18 |
|
| `forecast` (`ping-forecast`, library) | the flood forecast (`src/ml` serving path); run by `ping-collector` with ntfy | phase 2: in production since 2026-10-18 |
|
||||||
|
| `collector`: `src/web` | the FastAPI app (`src/web_api.py`): dashboard, read API, `/docs` | phase 3: built, cutover pending |
|
||||||
|
|
||||||
## ping-collector
|
## ping-collector
|
||||||
|
|
||||||
@@ -19,7 +20,10 @@ the database advances.
|
|||||||
```text
|
```text
|
||||||
ping-collector run # the service: startup gap fill, RID at :05 (+10 min retries
|
ping-collector run # the service: startup gap fill, RID at :05 (+10 min retries
|
||||||
# while RID is behind), HII/dams/rain once per clock hour;
|
# while RID is behind), HII/dams/rain once per clock hour;
|
||||||
# with FORECAST_IN_COLLECTOR=true also forecast + ntfy
|
# with FORECAST_IN_COLLECTOR=true also forecast + ntfy/Matrix;
|
||||||
|
# with WEB_LISTEN the website and API
|
||||||
|
ping-collector serve [--listen A] [--no-analytics]
|
||||||
|
# the website and API only (default 127.0.0.1:8001)
|
||||||
ping-collector once [--dry-run] # one cycle of every source; --dry-run writes nothing
|
ping-collector once [--dry-run] # one cycle of every source; --dry-run writes nothing
|
||||||
# and compares the RID fetch with the database
|
# and compares the RID fetch with the database
|
||||||
ping-collector fill-gaps <days|all> # re-fetch RID reports with holes
|
ping-collector fill-gaps <days|all> # re-fetch RID reports with holes
|
||||||
@@ -39,7 +43,7 @@ only its line number, never its value. Variables already set (e.g. by systemd's
|
|||||||
`GAP_FILL_DAYS_ON_START` (14), `ENABLE_HII_COLLECTION`,
|
`GAP_FILL_DAYS_ON_START` (14), `ENABLE_HII_COLLECTION`,
|
||||||
`ENABLE_RESERVOIR_COLLECTION`, `ENABLE_RAIN_COLLECTION`, `HII_BASIN_CODE` (6),
|
`ENABLE_RESERVOIR_COLLECTION`, `ENABLE_RAIN_COLLECTION`, `HII_BASIN_CODE` (6),
|
||||||
`STATION_CONFIG_PATH` (falls back to the bundled `src/data/stations.json`),
|
`STATION_CONFIG_PATH` (falls back to the bundled `src/data/stations.json`),
|
||||||
`SCRAPING_STATUS_FILE` (status JSON served by the API's `/scraping/status`),
|
`SCRAPING_STATUS_FILE` (status JSON for `/scraping/status` when the website runs elsewhere),
|
||||||
`LOG_LEVEL` / `RUST_LOG`; for phase 2 `FORECAST_IN_COLLECTOR` (false),
|
`LOG_LEVEL` / `RUST_LOG`; for phase 2 `FORECAST_IN_COLLECTOR` (false),
|
||||||
`MODELS_DIR` (`models`), `NTFY_SERVER` / `NTFY_PUBLISH_URL` / `NTFY_TOPIC_PREFIX`
|
`MODELS_DIR` (`models`), `NTFY_SERVER` / `NTFY_PUBLISH_URL` / `NTFY_TOPIC_PREFIX`
|
||||||
/ `NTFY_TOKEN` / `PUBLIC_URL` (as the Python app), `RID_STALE_ALERT_HOURS` (18,
|
/ `NTFY_TOKEN` / `PUBLIC_URL` (as the Python app), `RID_STALE_ALERT_HOURS` (18,
|
||||||
@@ -105,6 +109,37 @@ Verified:
|
|||||||
`src/notify.py` (thresholds, capacity guard, outlook, feed gaps, ntfy
|
`src/notify.py` (thresholds, capacity guard, outlook, feed gaps, ntfy
|
||||||
outages); the Rust notifier sends the same 751 messages word for word.
|
outages); the Rust notifier sends the same 751 messages word for word.
|
||||||
|
|
||||||
|
## Website and API (phase 3)
|
||||||
|
|
||||||
|
`src/web` serves what the FastAPI app served: the dashboard
|
||||||
|
(`STATIC_DIR`/dashboard.html, default `src/static`, loaded at start), the
|
||||||
|
crawler files, `/static`, `/docs` + `/redoc` + `/openapi.json`, and every API
|
||||||
|
route with the same parameters, JSON (field order included), status codes,
|
||||||
|
`{"detail": ...}` errors, Cache-Control, gzip, CORS (`CORS_ALLOW_ORIGINS`),
|
||||||
|
Umami API tracking (`UMAMI_*`) and response caches (`*_CACHE_TTL_SECONDS`).
|
||||||
|
Admin routes need `ADMIN_API_KEY`. With `WEB_LISTEN=0.0.0.0:8000` it runs
|
||||||
|
inside `ping-collector run` (one process collects, forecasts, notifies and
|
||||||
|
serves; `/scraping/status` and `POST /scrape/trigger` then talk to the
|
||||||
|
collection loop directly); `serve` runs it alone.
|
||||||
|
|
||||||
|
- Parameters are declared once per route (`web/params.rs`): the same list
|
||||||
|
validates the request (pydantic-style 422s) and builds the OpenAPI
|
||||||
|
document (`web/openapi.rs`), so `/docs` cannot drift from what is accepted.
|
||||||
|
The component schemas are FastAPI's (`web/openapi_components.json`).
|
||||||
|
- Python's output conventions are kept where they show: `isoformat()`
|
||||||
|
timestamps, `round()`, numpy's pairwise sums for the catchment means
|
||||||
|
(`web/pyfmt.rs`), the skill metrics' summation order.
|
||||||
|
- `scripts/api_parity.py --python URL --rust URL` compares the two live on
|
||||||
|
one database; `install_collector.sh --check-api` / `--cutover-api` run it
|
||||||
|
on the server against `ping-collector serve` on port 8001.
|
||||||
|
|
||||||
|
Matrix (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, `MATRIX_ROOM_ID`): the
|
||||||
|
notifier sends the room the same transitions as ntfy (per station, outlook,
|
||||||
|
feed; not ntfy's per-topic summaries), each once, with its own `matrix:` keys
|
||||||
|
in `notification_state` (seeded from the ntfy ones on first use, so nothing
|
||||||
|
already announced is repeated). It replaces the `run.py --alert-check` cron
|
||||||
|
job, which the install script comments out.
|
||||||
|
|
||||||
### Develop
|
### Develop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -142,6 +177,9 @@ cargo build --release --locked --manifest-path rust/Cargo.toml # or BIN=<CI ar
|
|||||||
sudo bash scripts/install_collector.sh # install + checks, nothing switched
|
sudo bash scripts/install_collector.sh # install + checks, nothing switched
|
||||||
sudo bash scripts/install_collector.sh --cutover # Rust collects from now on
|
sudo bash scripts/install_collector.sh --cutover # Rust collects from now on
|
||||||
sudo bash scripts/install_collector.sh --cutover-forecast # Rust also forecasts and notifies
|
sudo bash scripts/install_collector.sh --cutover-forecast # Rust also forecasts and notifies
|
||||||
|
sudo bash scripts/install_collector.sh --check-api # compare the Rust API with FastAPI
|
||||||
|
sudo bash scripts/install_collector.sh --cutover-api # Rust serves the website on :8000
|
||||||
|
sudo bash scripts/install_collector.sh --rollback-api # FastAPI serves the website again
|
||||||
sudo bash scripts/install_collector.sh --rollback-forecast # forecasts + ntfy back to Python
|
sudo bash scripts/install_collector.sh --rollback-forecast # forecasts + ntfy back to Python
|
||||||
sudo bash scripts/install_collector.sh --rollback # Python does everything again
|
sudo bash scripts/install_collector.sh --rollback # Python does everything again
|
||||||
journalctl -u ping-collector -f
|
journalctl -u ping-collector -f
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ping-collector"
|
name = "ping-collector"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Ping River Monitor data collector: RID gauges, HII/ThaiWater, RID dams, Open-Meteo rain -> PostgreSQL"
|
description = "Ping River Monitor service: collection (RID, HII/ThaiWater, RID dams, Open-Meteo), forecast, notifications and the website/API"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -10,8 +10,10 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
form_urlencoded.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
ping-forecast = { path = "../forecast" }
|
ping-forecast = { path = "../forecast" }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
@@ -19,6 +21,8 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
|||||||
+135
-35
@@ -48,30 +48,76 @@ pub struct Config {
|
|||||||
pub public_url: String,
|
pub public_url: String,
|
||||||
/// Report RID lagging this long while ThaiWater is current (0 = never).
|
/// Report RID lagging this long while ThaiWater is current (0 = never).
|
||||||
pub rid_stale_alert_hours: f64,
|
pub rid_stale_alert_hours: f64,
|
||||||
|
/// Public ntfy URL (what subscribers use), shown by /api/notifications.
|
||||||
|
pub ntfy_server: Option<String>,
|
||||||
|
/// MATRIX_*: the notifications also go to this room (off when unset).
|
||||||
|
pub matrix: Option<MatrixConfig>,
|
||||||
|
pub web: WebConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn var(name: &str) -> Option<String> {
|
#[derive(Clone, Debug)]
|
||||||
env::var(name)
|
pub struct MatrixConfig {
|
||||||
.ok()
|
pub homeserver: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub room_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The website and read API (src/web_api.py's settings, same names).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WebConfig {
|
||||||
|
/// WEB_LISTEN: `run` also serves the website on this address
|
||||||
|
/// (e.g. 0.0.0.0:8000); unset = collection only.
|
||||||
|
pub listen: Option<String>,
|
||||||
|
/// Dashboard, robots/llms/sitemap and /static files.
|
||||||
|
pub static_dir: PathBuf,
|
||||||
|
/// X-API-Key for the station editing, /config and /scrape/trigger;
|
||||||
|
/// unset = those endpoints are disabled.
|
||||||
|
pub admin_api_key: Option<String>,
|
||||||
|
pub cors_allow_origins: Vec<String>,
|
||||||
|
pub umami_api_url: String,
|
||||||
|
/// Empty = no server-side analytics.
|
||||||
|
pub umami_website_id: String,
|
||||||
|
pub umami_track_api: bool,
|
||||||
|
pub hii_cache_ttl_s: u64,
|
||||||
|
pub latest_cache_ttl_s: u64,
|
||||||
|
pub health_cache_ttl_s: u64,
|
||||||
|
/// A stored forecast older than this is served marked `X-Data-Stale`.
|
||||||
|
pub forecast_max_age_s: u64,
|
||||||
|
pub thaiwater_api_key: Option<String>,
|
||||||
|
pub request_timeout_s: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variable lookup (the process environment, or a map in tests).
|
||||||
|
struct Vars<'a>(&'a dyn Fn(&str) -> Option<String>);
|
||||||
|
|
||||||
|
impl Vars<'_> {
|
||||||
|
fn var(&self, name: &str) -> Option<String> {
|
||||||
|
(self.0)(name)
|
||||||
.map(|v| v.trim().to_string())
|
.map(|v| v.trim().to_string())
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flag(name: &str, default: bool) -> bool {
|
/// `os.getenv(name, default)`: a variable set to "" stays "".
|
||||||
var(name).map_or(default, |v| {
|
fn raw_var(&self, name: &str, default: &str) -> String {
|
||||||
|
(self.0)(name).map_or_else(|| default.to_string(), |v| v.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flag(&self, name: &str, default: bool) -> bool {
|
||||||
|
self.var(name).map_or(default, |v| {
|
||||||
matches!(v.to_lowercase().as_str(), "1" | "true" | "yes")
|
matches!(v.to_lowercase().as_str(), "1" | "true" | "yes")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn number<T: std::str::FromStr>(name: &str, default: T) -> Result<T>
|
fn number<T: std::str::FromStr>(&self, name: &str, default: T) -> Result<T>
|
||||||
where
|
where
|
||||||
T::Err: std::fmt::Display,
|
T::Err: std::fmt::Display,
|
||||||
{
|
{
|
||||||
match var(name) {
|
match self.var(name) {
|
||||||
None => Ok(default),
|
None => Ok(default),
|
||||||
Some(v) => v.parse().map_err(|e| anyhow::anyhow!("{name}={v:?}: {e}")),
|
Some(v) => v.parse().map_err(|e| anyhow::anyhow!("{name}={v:?}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Percent-encode everything outside RFC 3986 "unreserved" (as Python's
|
/// Percent-encode everything outside RFC 3986 "unreserved" (as Python's
|
||||||
/// `urllib.parse.quote(password, safe="")`).
|
/// `urllib.parse.quote(password, safe="")`).
|
||||||
@@ -101,52 +147,106 @@ pub fn normalize_database_url(url: &str) -> String {
|
|||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let database_url = match var("POSTGRES_CONNECTION_STRING") {
|
Self::from_vars(&|name| env::var(name).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_vars(get: &dyn Fn(&str) -> Option<String>) -> Result<Self> {
|
||||||
|
let v = Vars(get);
|
||||||
|
let database_url = match v.var("POSTGRES_CONNECTION_STRING") {
|
||||||
Some(url) => normalize_database_url(&url),
|
Some(url) => normalize_database_url(&url),
|
||||||
None => {
|
None => {
|
||||||
let password = var("POSTGRES_PASSWORD")
|
let password = v
|
||||||
|
.var("POSTGRES_PASSWORD")
|
||||||
.context("POSTGRES_CONNECTION_STRING or POSTGRES_PASSWORD must be set")?;
|
.context("POSTGRES_CONNECTION_STRING or POSTGRES_PASSWORD must be set")?;
|
||||||
format!(
|
format!(
|
||||||
"postgres://{}:{}@{}:{}/{}",
|
"postgres://{}:{}@{}:{}/{}",
|
||||||
encode_userinfo(&var("POSTGRES_USER").unwrap_or_else(|| "postgres".into())),
|
encode_userinfo(&v.var("POSTGRES_USER").unwrap_or_else(|| "postgres".into())),
|
||||||
encode_userinfo(&password),
|
encode_userinfo(&password),
|
||||||
var("POSTGRES_HOST").unwrap_or_else(|| "localhost".into()),
|
v.var("POSTGRES_HOST").unwrap_or_else(|| "localhost".into()),
|
||||||
var("POSTGRES_PORT").unwrap_or_else(|| "5432".into()),
|
v.var("POSTGRES_PORT").unwrap_or_else(|| "5432".into()),
|
||||||
var("POSTGRES_DB").unwrap_or_else(|| "water_monitoring".into()),
|
v.var("POSTGRES_DB")
|
||||||
|
.unwrap_or_else(|| "water_monitoring".into()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(db_type) = var("DB_TYPE")
|
if let Some(db_type) = v.var("DB_TYPE")
|
||||||
&& db_type != "postgresql"
|
&& db_type != "postgresql"
|
||||||
{
|
{
|
||||||
bail!("ping-collector supports PostgreSQL only (DB_TYPE={db_type})");
|
bail!("ping-collector supports PostgreSQL only (DB_TYPE={db_type})");
|
||||||
}
|
}
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
database_url,
|
database_url,
|
||||||
rid_api_url: var("RID_API_URL").unwrap_or_else(|| RID_API_URL.into()),
|
rid_api_url: v.var("RID_API_URL").unwrap_or_else(|| RID_API_URL.into()),
|
||||||
hii_api_base: var("HII_API_BASE").unwrap_or_else(|| HII_API_BASE.into()),
|
hii_api_base: v.var("HII_API_BASE").unwrap_or_else(|| HII_API_BASE.into()),
|
||||||
dams_url: var("RID_DAMS_URL").unwrap_or_else(|| RID_DAMS_URL.into()),
|
dams_url: v.var("RID_DAMS_URL").unwrap_or_else(|| RID_DAMS_URL.into()),
|
||||||
openmeteo_url: var("OPENMETEO_URL").unwrap_or_else(|| OPENMETEO_URL.into()),
|
openmeteo_url: v
|
||||||
hii_enabled: flag("ENABLE_HII_COLLECTION", true),
|
.var("OPENMETEO_URL")
|
||||||
dams_enabled: flag("ENABLE_RESERVOIR_COLLECTION", true),
|
.unwrap_or_else(|| OPENMETEO_URL.into()),
|
||||||
rain_enabled: flag("ENABLE_RAIN_COLLECTION", true),
|
hii_enabled: v.flag("ENABLE_HII_COLLECTION", true),
|
||||||
hii_basin_code: number("HII_BASIN_CODE", 6)?,
|
dams_enabled: v.flag("ENABLE_RESERVOIR_COLLECTION", true),
|
||||||
poll_offset_minutes: number("RID_POLL_OFFSET_MINUTES", 5)?,
|
rain_enabled: v.flag("ENABLE_RAIN_COLLECTION", true),
|
||||||
retry_minutes: number("RID_RETRY_MINUTES", 10)?,
|
hii_basin_code: v.number("HII_BASIN_CODE", 6)?,
|
||||||
gap_fill_days_on_start: number("GAP_FILL_DAYS_ON_START", 14)?,
|
poll_offset_minutes: v.number("RID_POLL_OFFSET_MINUTES", 5)?,
|
||||||
station_config_path: var("STATION_CONFIG_PATH")
|
retry_minutes: v.number("RID_RETRY_MINUTES", 10)?,
|
||||||
|
gap_fill_days_on_start: v.number("GAP_FILL_DAYS_ON_START", 14)?,
|
||||||
|
station_config_path: v
|
||||||
|
.var("STATION_CONFIG_PATH")
|
||||||
.unwrap_or_else(|| "stations.json".into())
|
.unwrap_or_else(|| "stations.json".into())
|
||||||
.into(),
|
.into(),
|
||||||
status_file: var("SCRAPING_STATUS_FILE").map(PathBuf::from),
|
status_file: v.var("SCRAPING_STATUS_FILE").map(PathBuf::from),
|
||||||
forecast_enabled: flag("FORECAST_IN_COLLECTOR", false),
|
forecast_enabled: v.flag("FORECAST_IN_COLLECTOR", false),
|
||||||
models_dir: var("MODELS_DIR").unwrap_or_else(|| "models".into()).into(),
|
models_dir: v
|
||||||
|
.var("MODELS_DIR")
|
||||||
|
.unwrap_or_else(|| "models".into())
|
||||||
|
.into(),
|
||||||
// as src/config.py: off unless NTFY_SERVER is set
|
// as src/config.py: off unless NTFY_SERVER is set
|
||||||
ntfy_publish_url: var("NTFY_SERVER")
|
ntfy_publish_url: v
|
||||||
.map(|server| var("NTFY_PUBLISH_URL").unwrap_or(server)),
|
.var("NTFY_SERVER")
|
||||||
ntfy_token: var("NTFY_TOKEN"),
|
.map(|server| v.var("NTFY_PUBLISH_URL").unwrap_or(server)),
|
||||||
ntfy_topic_prefix: var("NTFY_TOPIC_PREFIX").unwrap_or_else(|| "ping".into()),
|
ntfy_token: v.var("NTFY_TOKEN"),
|
||||||
public_url: var("PUBLIC_URL").unwrap_or_else(|| "https://water.buildfor.life/".into()),
|
ntfy_topic_prefix: v.var("NTFY_TOPIC_PREFIX").unwrap_or_else(|| "ping".into()),
|
||||||
rid_stale_alert_hours: number("RID_STALE_ALERT_HOURS", 18.0)?,
|
public_url: v
|
||||||
|
.var("PUBLIC_URL")
|
||||||
|
.unwrap_or_else(|| "https://water.buildfor.life/".into()),
|
||||||
|
rid_stale_alert_hours: v.number("RID_STALE_ALERT_HOURS", 18.0)?,
|
||||||
|
ntfy_server: v.var("NTFY_SERVER"),
|
||||||
|
matrix: match (v.var("MATRIX_ACCESS_TOKEN"), v.var("MATRIX_ROOM_ID")) {
|
||||||
|
(Some(access_token), Some(room_id)) => Some(MatrixConfig {
|
||||||
|
homeserver: v
|
||||||
|
.var("MATRIX_HOMESERVER")
|
||||||
|
.unwrap_or_else(|| "https://matrix.org".into()),
|
||||||
|
access_token,
|
||||||
|
room_id,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
web: WebConfig {
|
||||||
|
listen: v.var("WEB_LISTEN"),
|
||||||
|
static_dir: v
|
||||||
|
.var("STATIC_DIR")
|
||||||
|
.unwrap_or_else(|| "src/static".into())
|
||||||
|
.into(),
|
||||||
|
admin_api_key: v.var("ADMIN_API_KEY"),
|
||||||
|
cors_allow_origins: v
|
||||||
|
.raw_var("CORS_ALLOW_ORIGINS", "")
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|o| !o.is_empty())
|
||||||
|
.map(String::from)
|
||||||
|
.collect(),
|
||||||
|
umami_api_url: v
|
||||||
|
.var("UMAMI_API_URL")
|
||||||
|
.unwrap_or_else(|| "https://stats.buildfor.life/api/send".into()),
|
||||||
|
umami_website_id: v
|
||||||
|
.raw_var("UMAMI_WEBSITE_ID", "00b2be73-8f5f-4400-9029-3be852eb08f7"),
|
||||||
|
umami_track_api: v.flag("UMAMI_TRACK_API", true),
|
||||||
|
hii_cache_ttl_s: v.number("HII_CACHE_TTL_SECONDS", 120)?,
|
||||||
|
latest_cache_ttl_s: v.number("LATEST_CACHE_TTL_SECONDS", 45)?,
|
||||||
|
health_cache_ttl_s: v.number("HEALTH_CACHE_TTL_SECONDS", 30)?,
|
||||||
|
forecast_max_age_s: v.number("FORECAST_MAX_AGE_SECONDS", 7200)?,
|
||||||
|
thaiwater_api_key: v.var("THAIWATER_API_KEY"),
|
||||||
|
request_timeout_s: v.number("REQUEST_TIMEOUT", 30)?,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if cfg.poll_offset_minutes >= 60 {
|
if cfg.poll_offset_minutes >= 60 {
|
||||||
bail!("RID_POLL_OFFSET_MINUTES must be between 0 and 59");
|
bail!("RID_POLL_OFFSET_MINUTES must be between 0 and 59");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use sqlx::PgPool;
|
|||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
||||||
pub async fn connect(url: &str) -> Result<PgPool> {
|
pub async fn connect(url: &str, max_connections: u32) -> Result<PgPool> {
|
||||||
use sqlx::ConnectOptions;
|
use sqlx::ConnectOptions;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
// sqlx warns about any statement over 1 s; the whole-history gap scan
|
// sqlx warns about any statement over 1 s; the whole-history gap scan
|
||||||
@@ -23,7 +23,7 @@ pub async fn connect(url: &str) -> Result<PgPool> {
|
|||||||
.context("invalid database URL")?
|
.context("invalid database URL")?
|
||||||
.log_slow_statements(log::LevelFilter::Warn, std::time::Duration::from_secs(30));
|
.log_slow_statements(log::LevelFilter::Warn, std::time::Duration::from_secs(30));
|
||||||
PgPoolOptions::new()
|
PgPoolOptions::new()
|
||||||
.max_connections(4)
|
.max_connections(max_connections)
|
||||||
.acquire_timeout(std::time::Duration::from_secs(30))
|
.acquire_timeout(std::time::Duration::from_secs(30))
|
||||||
.connect_with(options)
|
.connect_with(options)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ use std::collections::HashMap;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
struct TestDb {
|
pub(crate) struct TestDb {
|
||||||
pool: PgPool,
|
pub(crate) pool: PgPool,
|
||||||
admin: PgPool,
|
admin: PgPool,
|
||||||
schema: String,
|
schema: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestDb {
|
impl TestDb {
|
||||||
async fn new() -> Option<Self> {
|
pub(crate) async fn new() -> Option<Self> {
|
||||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||||
eprintln!("TEST_DATABASE_URL not set: skipping database test");
|
eprintln!("TEST_DATABASE_URL not set: skipping database test");
|
||||||
return None;
|
return None;
|
||||||
@@ -58,7 +58,7 @@ impl TestDb {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drop(self) {
|
pub(crate) async fn drop(self) {
|
||||||
self.pool.close().await;
|
self.pool.close().await;
|
||||||
sqlx::query(&format!("DROP SCHEMA {} CASCADE", self.schema))
|
sqlx::query(&format!("DROP SCHEMA {} CASCADE", self.schema))
|
||||||
.execute(&self.admin)
|
.execute(&self.admin)
|
||||||
|
|||||||
@@ -130,12 +130,23 @@ pub fn summarize(s: &Snapshot) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ntfy (NTFY_SERVER) and/or Matrix (MATRIX_ACCESS_TOKEN + MATRIX_ROOM_ID);
|
||||||
|
/// `None` when neither is configured.
|
||||||
pub fn notifier(cfg: &Config, http: reqwest::Client) -> Option<Notifier> {
|
pub fn notifier(cfg: &Config, http: reqwest::Client) -> Option<Notifier> {
|
||||||
let url = cfg.ntfy_publish_url.clone()?;
|
let ntfy = cfg
|
||||||
|
.ntfy_publish_url
|
||||||
|
.clone()
|
||||||
|
.map(|publish_url| notify::Ntfy {
|
||||||
|
publish_url,
|
||||||
|
token: cfg.ntfy_token.clone(),
|
||||||
|
});
|
||||||
|
if ntfy.is_none() && cfg.matrix.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
Some(Notifier {
|
Some(Notifier {
|
||||||
http,
|
http,
|
||||||
publish_url: url,
|
ntfy,
|
||||||
token: cfg.ntfy_token.clone(),
|
matrix: cfg.matrix.clone(),
|
||||||
dashboard_url: cfg.public_url.clone(),
|
dashboard_url: cfg.public_url.clone(),
|
||||||
settings: Settings {
|
settings: Settings {
|
||||||
prefix: cfg.ntfy_topic_prefix.clone(),
|
prefix: cfg.ntfy_topic_prefix.clone(),
|
||||||
@@ -164,14 +175,27 @@ pub struct Forecaster {
|
|||||||
impl Forecaster {
|
impl Forecaster {
|
||||||
pub fn new(cfg: &Config, http: reqwest::Client) -> Self {
|
pub fn new(cfg: &Config, http: reqwest::Client) -> Self {
|
||||||
let notifier = notifier(cfg, http);
|
let notifier = notifier(cfg, http);
|
||||||
match ¬ifier {
|
match notifier
|
||||||
Some(n) => tracing::info!(
|
.as_ref()
|
||||||
|
.and_then(|n| n.ntfy.as_ref().map(|t| (n, t)))
|
||||||
|
{
|
||||||
|
Some((n, t)) => tracing::info!(
|
||||||
"ntfy notifications: publish to {}, topics {}-*",
|
"ntfy notifications: publish to {}, topics {}-*",
|
||||||
n.publish_url,
|
t.publish_url,
|
||||||
n.settings.prefix
|
n.settings.prefix
|
||||||
),
|
),
|
||||||
None => tracing::info!("ntfy notifications off (NTFY_SERVER not set)"),
|
None => tracing::info!("ntfy notifications off (NTFY_SERVER not set)"),
|
||||||
}
|
}
|
||||||
|
match &cfg.matrix {
|
||||||
|
Some(m) => tracing::info!(
|
||||||
|
"Matrix notifications: room {} on {}",
|
||||||
|
m.room_id,
|
||||||
|
m.homeserver
|
||||||
|
),
|
||||||
|
None => tracing::info!(
|
||||||
|
"Matrix notifications off (MATRIX_ACCESS_TOKEN/MATRIX_ROOM_ID not set)"
|
||||||
|
),
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
store: ModelStore::new(&cfg.models_dir),
|
store: ModelStore::new(&cfg.models_dir),
|
||||||
notifier,
|
notifier,
|
||||||
@@ -236,7 +260,7 @@ impl Forecaster {
|
|||||||
notified = sent.len();
|
notified = sent.len();
|
||||||
if !sent.is_empty() {
|
if !sent.is_empty() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"ntfy: published {}",
|
"notifications: published {}",
|
||||||
sent.iter()
|
sent.iter()
|
||||||
.map(|s| format!("{}: {}", s.topic, s.title))
|
.map(|s| format!("{}: {}", s.topic, s.title))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -244,7 +268,7 @@ impl Forecaster {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("ntfy notify cycle failed: {e:#}"),
|
Err(e) => tracing::warn!("notify cycle failed: {e:#}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(RunInfo {
|
Ok(RunInfo {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
//! Replaces the collection half of the Python web app (RID hourly gauges,
|
//! Replaces the collection half of the Python web app (RID hourly gauges,
|
||||||
//! HII/ThaiWater rain + water level, RID dams, Open-Meteo rain) and, with
|
//! HII/ThaiWater rain + water level, RID dams, Open-Meteo rain) and, with
|
||||||
//! FORECAST_IN_COLLECTOR=true, the flood forecast (rust/forecast) and the
|
//! FORECAST_IN_COLLECTOR=true, the flood forecast (rust/forecast) and the
|
||||||
//! ntfy notifications. Writes the same PostgreSQL tables as the Python app,
|
//! ntfy/Matrix notifications, and with WEB_LISTEN the website and read API
|
||||||
//! which then runs with `COLLECTION_ENABLED=false`. See docs/RUST_REWRITE.md.
|
//! (the port of the FastAPI app). Writes the same PostgreSQL tables as the
|
||||||
|
//! Python app did. See docs/RUST_REWRITE.md.
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod dams;
|
mod dams;
|
||||||
@@ -20,6 +21,7 @@ mod rid;
|
|||||||
mod service;
|
mod service;
|
||||||
mod stations;
|
mod stations;
|
||||||
mod util;
|
mod util;
|
||||||
|
mod web;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
@@ -38,8 +40,20 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Run the collection service (what systemd starts)
|
/// Run the service (what systemd starts): collection, with
|
||||||
|
/// FORECAST_IN_COLLECTOR=true forecasts and notifications, with WEB_LISTEN
|
||||||
|
/// the website and API
|
||||||
Run,
|
Run,
|
||||||
|
/// Serve only the website and API (no collection), e.g. next to the
|
||||||
|
/// running service for a comparison
|
||||||
|
Serve {
|
||||||
|
/// Address to listen on [default: WEB_LISTEN, else 127.0.0.1:8001]
|
||||||
|
#[arg(long)]
|
||||||
|
listen: Option<String>,
|
||||||
|
/// Do not report requests to Umami (comparison runs)
|
||||||
|
#[arg(long)]
|
||||||
|
no_analytics: bool,
|
||||||
|
},
|
||||||
/// One cycle of every source, then exit
|
/// One cycle of every source, then exit
|
||||||
Once {
|
Once {
|
||||||
/// Fetch and compare with the database, write nothing
|
/// Fetch and compare with the database, write nothing
|
||||||
@@ -132,18 +146,33 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(cli: Cli) -> Result<()> {
|
async fn run(cli: Cli) -> Result<()> {
|
||||||
let cfg = config::Config::from_env()?;
|
let mut cfg = config::Config::from_env()?;
|
||||||
|
if let Command::Serve {
|
||||||
|
no_analytics: true, ..
|
||||||
|
} = cli.command
|
||||||
|
{
|
||||||
|
cfg.web.umami_track_api = false;
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"ping-collector {} -> {}",
|
"ping-collector {} -> {}",
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
cfg.redacted_database_url()
|
cfg.redacted_database_url()
|
||||||
);
|
);
|
||||||
let pool = db::connect(&cfg.database_url).await?;
|
// the website answers concurrent requests; collection alone needs few
|
||||||
|
let serving = matches!(cli.command, Command::Serve { .. })
|
||||||
|
|| (matches!(cli.command, Command::Run) && cfg.web.listen.is_some());
|
||||||
|
let pool = db::connect(&cfg.database_url, if serving { 12 } else { 4 }).await?;
|
||||||
let stations = stations::Stations::load(&cfg.station_config_path)?;
|
let stations = stations::Stations::load(&cfg.station_config_path)?;
|
||||||
let collector = service::Collector::new(cfg, pool, stations)?;
|
let collector = service::Collector::new(cfg, pool, stations)?;
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Run => collector.run().await,
|
Command::Run => collector.run().await,
|
||||||
|
Command::Serve { listen, .. } => {
|
||||||
|
let listen = listen
|
||||||
|
.or_else(|| collector.cfg.web.listen.clone())
|
||||||
|
.unwrap_or_else(|| "127.0.0.1:8001".into());
|
||||||
|
collector.serve(listen).await
|
||||||
|
}
|
||||||
Command::Once { dry_run } => collector.once(dry_run).await,
|
Command::Once { dry_run } => collector.once(dry_run).await,
|
||||||
Command::FillGaps { days } => {
|
Command::FillGaps { days } => {
|
||||||
let days = match days.as_str() {
|
let days = match days.as_str() {
|
||||||
@@ -157,7 +186,7 @@ async fn run(cli: Cli) -> Result<()> {
|
|||||||
let filled = rid::fill_gaps(
|
let filled = rid::fill_gaps(
|
||||||
&collector.rid,
|
&collector.rid,
|
||||||
&collector.pool,
|
&collector.pool,
|
||||||
&collector.stations,
|
&collector.stations(),
|
||||||
days,
|
days,
|
||||||
util::bangkok_now(),
|
util::bangkok_now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+236
-10
@@ -1,5 +1,8 @@
|
|||||||
//! Public flood notifications over ntfy: the port of src/notify.py, run by
|
//! Public flood notifications over ntfy, and the same transitions to a
|
||||||
//! the collector when FORECAST_IN_COLLECTOR=true.
|
//! Matrix room: the port of src/notify.py, run by the collector when
|
||||||
|
//! FORECAST_IN_COLLECTOR=true. (Matrix replaces the old `run.py
|
||||||
|
//! --alert-check` cron job, which repeated an alert on every run while a
|
||||||
|
//! level stayed high.)
|
||||||
//!
|
//!
|
||||||
//! Same topics, texts, hysteresis, capacity guard, outlook and persisted
|
//! Same topics, texts, hysteresis, capacity guard, outlook and persisted
|
||||||
//! state keys (`notification_state`) as Python, so a cutover neither repeats
|
//! state keys (`notification_state`) as Python, so a cutover neither repeats
|
||||||
@@ -9,6 +12,7 @@
|
|||||||
//! "stale" only when neither source has a reading for 3 h; RID lagging alone
|
//! "stale" only when neither source has a reading for 3 h; RID lagging alone
|
||||||
//! is reported separately after RID_STALE_ALERT_HOURS.
|
//! is reported separately after RID_STALE_ALERT_HOURS.
|
||||||
|
|
||||||
|
use crate::config::MatrixConfig;
|
||||||
use crate::db::{self, LatestReading};
|
use crate::db::{self, LatestReading};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::{Duration, NaiveDateTime};
|
use chrono::{Duration, NaiveDateTime};
|
||||||
@@ -425,22 +429,105 @@ pub fn evaluate(
|
|||||||
plan
|
plan
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Posts to ntfy and records delivered transitions.
|
/// ntfy publishing target.
|
||||||
pub struct Notifier {
|
pub struct Ntfy {
|
||||||
pub http: reqwest::Client,
|
|
||||||
pub publish_url: String,
|
pub publish_url: String,
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State keys of the Matrix channel: the same transitions as ntfy, tracked
|
||||||
|
/// separately so a failed delivery on one channel is retried on that one
|
||||||
|
/// only.
|
||||||
|
const MATRIX_PREFIX: &str = "matrix:";
|
||||||
|
|
||||||
|
/// Posts to ntfy and Matrix and records delivered transitions.
|
||||||
|
pub struct Notifier {
|
||||||
|
pub http: reqwest::Client,
|
||||||
|
pub ntfy: Option<Ntfy>,
|
||||||
|
pub matrix: Option<MatrixConfig>,
|
||||||
pub dashboard_url: String,
|
pub dashboard_url: String,
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ntfy tag shortcodes the notifications use, as emoji for Matrix.
|
||||||
|
fn emoji(tags: &[String]) -> &'static str {
|
||||||
|
match tags.first().map(String::as_str) {
|
||||||
|
Some("rotating_light") => "🚨",
|
||||||
|
Some("warning") => "⚠️",
|
||||||
|
Some("arrow_down") => "⬇️",
|
||||||
|
Some("white_check_mark") => "✅",
|
||||||
|
Some("crystal_ball") => "🔮",
|
||||||
|
Some("hourglass") => "⏳",
|
||||||
|
_ => "📊",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Matrix message: the ntfy title and text, in the small Markdown
|
||||||
|
/// subset `markdown_to_html` renders.
|
||||||
|
pub fn matrix_text(n: &Notification, dashboard_url: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{} **{}**\n{}\n\n📈 Dashboard: {dashboard_url}",
|
||||||
|
emoji(&n.tags),
|
||||||
|
n.title,
|
||||||
|
n.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `**bold**` removed, for Matrix's plain-text `body`.
|
||||||
|
pub fn strip_markdown(text: &str) -> String {
|
||||||
|
text.replace("**", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `alerting.markdown_to_matrix_html`: escape, `**bold**`, bare URLs as
|
||||||
|
/// links, newlines as `<br/>`.
|
||||||
|
pub fn markdown_to_html(text: &str) -> String {
|
||||||
|
let escaped = text
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">");
|
||||||
|
let mut bold = String::new();
|
||||||
|
let mut parts = escaped.split("**").peekable();
|
||||||
|
let mut open = false;
|
||||||
|
while let Some(part) = parts.next() {
|
||||||
|
bold.push_str(part);
|
||||||
|
if parts.peek().is_some() {
|
||||||
|
bold.push_str(if open { "</strong>" } else { "<strong>" });
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if open {
|
||||||
|
// an unpaired ** stays literal
|
||||||
|
if let Some(i) = bold.rfind("<strong>") {
|
||||||
|
bold.replace_range(i..i + "<strong>".len(), "**");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut rest = bold.as_str();
|
||||||
|
while let Some(i) = rest
|
||||||
|
.find("http://")
|
||||||
|
.into_iter()
|
||||||
|
.chain(rest.find("https://"))
|
||||||
|
.min()
|
||||||
|
{
|
||||||
|
out.push_str(&rest[..i]);
|
||||||
|
let url_len = rest[i..]
|
||||||
|
.find(|c: char| c.is_whitespace() || c == '<')
|
||||||
|
.unwrap_or(rest.len() - i);
|
||||||
|
let url = &rest[i..i + url_len];
|
||||||
|
out.push_str(&format!("<a href=\"{url}\">{url}</a>"));
|
||||||
|
rest = &rest[i + url_len..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out.replace('\n', "<br/>")
|
||||||
|
}
|
||||||
|
|
||||||
impl Notifier {
|
impl Notifier {
|
||||||
pub async fn publish(&self, n: &Notification) -> bool {
|
pub async fn publish(&self, ntfy: &Ntfy, n: &Notification) -> bool {
|
||||||
let mut req = self
|
let mut req = self
|
||||||
.http
|
.http
|
||||||
.post(format!(
|
.post(format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
self.publish_url.trim_end_matches('/'),
|
ntfy.publish_url.trim_end_matches('/'),
|
||||||
n.topic
|
n.topic
|
||||||
))
|
))
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
@@ -455,7 +542,7 @@ impl Notifier {
|
|||||||
if !n.tags.is_empty() {
|
if !n.tags.is_empty() {
|
||||||
req = req.header("Tags", n.tags.join(","));
|
req = req.header("Tags", n.tags.join(","));
|
||||||
}
|
}
|
||||||
if let Some(t) = &self.token {
|
if let Some(t) = &ntfy.token {
|
||||||
req = req.bearer_auth(t);
|
req = req.bearer_auth(t);
|
||||||
}
|
}
|
||||||
match req.send().await {
|
match req.send().await {
|
||||||
@@ -477,7 +564,106 @@ impl Notifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate and publish; returns the notifications delivered.
|
/// One Matrix room message (`MatrixNotifier.send_message`).
|
||||||
|
pub async fn send_matrix(&self, m: &MatrixConfig, n: &Notification) -> bool {
|
||||||
|
static TXN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
let text = matrix_text(n, &self.dashboard_url);
|
||||||
|
let txn = format!(
|
||||||
|
"ping-{}-{}",
|
||||||
|
chrono::Utc::now().timestamp_micros(),
|
||||||
|
TXN.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
let url = format!(
|
||||||
|
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{txn}",
|
||||||
|
m.homeserver.trim_end_matches('/'),
|
||||||
|
m.room_id
|
||||||
|
);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": strip_markdown(&text),
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"formatted_body": markdown_to_html(&text),
|
||||||
|
});
|
||||||
|
match self
|
||||||
|
.http
|
||||||
|
.put(url)
|
||||||
|
.bearer_auth(&m.access_token)
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.status().is_success() => true,
|
||||||
|
Ok(r) => {
|
||||||
|
let status = r.status();
|
||||||
|
let body = r.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
"Matrix: HTTP {status} {}",
|
||||||
|
body.chars().take(120).collect::<String>()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Matrix: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Matrix channel's states (keys without the prefix). On first use
|
||||||
|
/// they start as a copy of the ntfy states, so what ntfy already
|
||||||
|
/// announced is not sent again.
|
||||||
|
pub async fn matrix_states(
|
||||||
|
&self,
|
||||||
|
pool: &PgPool,
|
||||||
|
states: &HashMap<String, String>,
|
||||||
|
now: NaiveDateTime,
|
||||||
|
seed: bool,
|
||||||
|
) -> Result<HashMap<String, String>> {
|
||||||
|
let own: HashMap<String, String> = states
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| {
|
||||||
|
k.strip_prefix(MATRIX_PREFIX)
|
||||||
|
.map(|k| (k.to_string(), v.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !own.is_empty() {
|
||||||
|
return Ok(own);
|
||||||
|
}
|
||||||
|
let copy: HashMap<String, String> = states
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| !k.starts_with(MATRIX_PREFIX))
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
if seed {
|
||||||
|
for (k, v) in © {
|
||||||
|
db::set_notification_state(pool, &format!("{MATRIX_PREFIX}{k}"), v, None, now)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
db::set_notification_state(pool, "matrix:seeded", "yes", None, now).await?;
|
||||||
|
}
|
||||||
|
Ok(copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the Matrix channel would send: the transitions that carry a
|
||||||
|
/// state (per station, outlook, feed). The per-topic summaries ntfy also
|
||||||
|
/// gets ("Ping River: warning level reached") repeat those, so the room
|
||||||
|
/// does not get them.
|
||||||
|
pub fn matrix_plan(
|
||||||
|
&self,
|
||||||
|
readings: &[Reading],
|
||||||
|
forecasts: &[Forecast],
|
||||||
|
states: &HashMap<String, String>,
|
||||||
|
now: NaiveDateTime,
|
||||||
|
) -> Vec<Planned> {
|
||||||
|
evaluate(readings, forecasts, states, &self.settings, now)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p.state.is_some())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate and publish; returns the notifications delivered (Matrix
|
||||||
|
/// messages with topic "matrix").
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
&self,
|
&self,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
@@ -487,8 +673,9 @@ impl Notifier {
|
|||||||
) -> Result<Vec<Notification>> {
|
) -> Result<Vec<Notification>> {
|
||||||
let states = db::notification_states(pool).await?;
|
let states = db::notification_states(pool).await?;
|
||||||
let mut sent = vec![];
|
let mut sent = vec![];
|
||||||
|
if let Some(ntfy) = &self.ntfy {
|
||||||
for p in evaluate(readings, forecasts, &states, &self.settings, now) {
|
for p in evaluate(readings, forecasts, &states, &self.settings, now) {
|
||||||
if !self.publish(&p.notification).await {
|
if !self.publish(ntfy, &p.notification).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some((key, state, value)) = &p.state {
|
if let Some((key, state, value)) = &p.state {
|
||||||
@@ -496,6 +683,23 @@ impl Notifier {
|
|||||||
}
|
}
|
||||||
sent.push(p.notification);
|
sent.push(p.notification);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if let Some(m) = &self.matrix {
|
||||||
|
let mstates = self.matrix_states(pool, &states, now, true).await?;
|
||||||
|
for p in self.matrix_plan(readings, forecasts, &mstates, now) {
|
||||||
|
if !self.send_matrix(m, &p.notification).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((key, state, value)) = &p.state {
|
||||||
|
let key = format!("{MATRIX_PREFIX}{key}");
|
||||||
|
db::set_notification_state(pool, &key, state, *value, now).await?;
|
||||||
|
}
|
||||||
|
sent.push(Notification {
|
||||||
|
topic: "matrix".into(),
|
||||||
|
..p.notification
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(sent)
|
Ok(sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,6 +709,28 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matrix_message_rendering() {
|
||||||
|
let n = Notification {
|
||||||
|
topic: "ping-p1-warning".into(),
|
||||||
|
title: "Warning level at P.1".into(),
|
||||||
|
message:
|
||||||
|
"Nawarat Bridge: 3.75 m at 18 Oct 14:00, above the warning level of 3.70 m <x> & y."
|
||||||
|
.into(),
|
||||||
|
priority: 4,
|
||||||
|
tags: vec!["warning".into(), "P.1".into()],
|
||||||
|
};
|
||||||
|
let text = matrix_text(&n, "https://water.buildfor.life/");
|
||||||
|
assert_eq!(
|
||||||
|
markdown_to_html(&text),
|
||||||
|
"⚠️ <strong>Warning level at P.1</strong><br/>Nawarat Bridge: 3.75 m at 18 Oct 14:00, \
|
||||||
|
above the warning level of 3.70 m <x> & y.<br/><br/>📈 Dashboard: \
|
||||||
|
<a href=\"https://water.buildfor.life/\">https://water.buildfor.life/</a>"
|
||||||
|
);
|
||||||
|
assert!(strip_markdown(&text).starts_with("⚠️ Warning level at P.1\nNawarat"));
|
||||||
|
assert_eq!(markdown_to_html("a ** b"), "a ** b");
|
||||||
|
}
|
||||||
|
|
||||||
fn now() -> NaiveDateTime {
|
fn now() -> NaiveDateTime {
|
||||||
NaiveDate::from_ymd_opt(2026, 10, 1)
|
NaiveDate::from_ymd_opt(2026, 10, 1)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ use ping_forecast::ModelStore;
|
|||||||
use ping_forecast::rain::RainSeries;
|
use ping_forecast::rain::RainSeries;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
pub struct Collector {
|
pub struct Collector {
|
||||||
pub cfg: Config,
|
pub cfg: Config,
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
pub stations: Stations,
|
/// Shared with the website (the station CRUD API edits it).
|
||||||
|
pub stations: Arc<RwLock<Stations>>,
|
||||||
|
/// The last status written, shared with /scraping/status.
|
||||||
|
pub status: Arc<RwLock<serde_json::Value>>,
|
||||||
|
/// POST /scrape/trigger: poll now.
|
||||||
|
pub trigger: Arc<tokio::sync::Notify>,
|
||||||
pub rid: RidClient,
|
pub rid: RidClient,
|
||||||
hii: HiiClient,
|
hii: HiiClient,
|
||||||
dams: DamsClient,
|
dams: DamsClient,
|
||||||
@@ -61,10 +67,36 @@ impl Collector {
|
|||||||
http,
|
http,
|
||||||
cfg,
|
cfg,
|
||||||
pool,
|
pool,
|
||||||
stations,
|
stations: Arc::new(RwLock::new(stations)),
|
||||||
|
status: Arc::new(RwLock::new(json!({"is_running": false}))),
|
||||||
|
trigger: Arc::new(tokio::sync::Notify::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The station mapping as of now (the API may edit it between cycles).
|
||||||
|
pub fn stations(&self) -> Stations {
|
||||||
|
self.stations.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The website/API state over this collector's pool, stations and status.
|
||||||
|
pub fn web_state(&self, in_process: bool) -> crate::web::Shared {
|
||||||
|
crate::web::AppState::new(
|
||||||
|
self.cfg.clone(),
|
||||||
|
self.pool.clone(),
|
||||||
|
self.http.clone(),
|
||||||
|
self.stations.clone(),
|
||||||
|
in_process.then(|| self.status.clone()),
|
||||||
|
in_process.then(|| self.trigger.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ping-collector serve`: the website and API only; /scraping/status
|
||||||
|
/// reads the status file the collector service writes.
|
||||||
|
pub async fn serve(&self, listen: String) -> Result<()> {
|
||||||
|
db::ensure_schema(&self.pool).await?;
|
||||||
|
crate::web::serve(self.web_state(false), listen, shutdown_signal()).await
|
||||||
|
}
|
||||||
|
|
||||||
/// HII rain + water level, RID dams (today and yesterday), Open-Meteo.
|
/// HII rain + water level, RID dams (today and yesterday), Open-Meteo.
|
||||||
/// Each source is failure-isolated.
|
/// Each source is failure-isolated.
|
||||||
pub async fn collect_hourly(&self, now: NaiveDateTime) {
|
pub async fn collect_hourly(&self, now: NaiveDateTime) {
|
||||||
@@ -107,9 +139,6 @@ impl Collector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_status(&self, stats: &Stats, running: bool) {
|
fn write_status(&self, stats: &Stats, running: bool) {
|
||||||
let Some(path) = &self.cfg.status_file else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"is_running": running,
|
"is_running": running,
|
||||||
"total_runs": stats.total_runs,
|
"total_runs": stats.total_runs,
|
||||||
@@ -129,6 +158,10 @@ impl Collector {
|
|||||||
"notifications_sent": f.notified,
|
"notifications_sent": f.notified,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
*self.status.write().unwrap() = body.clone();
|
||||||
|
let Some(path) = &self.cfg.status_file else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
let result =
|
let result =
|
||||||
std::fs::write(&tmp, body.to_string()).and_then(|_| std::fs::rename(&tmp, path));
|
std::fs::write(&tmp, body.to_string()).and_then(|_| std::fs::rename(&tmp, path));
|
||||||
@@ -144,14 +177,40 @@ impl Collector {
|
|||||||
db::ensure_schema(&self.pool).await?;
|
db::ensure_schema(&self.pool).await?;
|
||||||
let shutdown = shutdown_signal();
|
let shutdown = shutdown_signal();
|
||||||
tokio::pin!(shutdown);
|
tokio::pin!(shutdown);
|
||||||
|
// the website, when WEB_LISTEN is set: stopped with the collector
|
||||||
|
let (stop_web, web_stopped) = tokio::sync::watch::channel(false);
|
||||||
|
let web = self.cfg.web.listen.clone().map(|listen| {
|
||||||
|
let state = self.web_state(true);
|
||||||
|
let mut rx = web_stopped.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let stop = async move {
|
||||||
|
let _ = rx.wait_for(|stop| *stop).await;
|
||||||
|
};
|
||||||
|
if let Err(e) = crate::web::serve(state, listen, stop).await {
|
||||||
|
tracing::error!("website stopped: {e:#}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let result = self.collect_loop(&mut shutdown).await;
|
||||||
|
let _ = stop_web.send(true);
|
||||||
|
if let Some(handle) = web {
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(10), handle).await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_loop(
|
||||||
|
&self,
|
||||||
|
shutdown: &mut std::pin::Pin<&mut impl std::future::Future<Output = ()>>,
|
||||||
|
) -> Result<()> {
|
||||||
if self.cfg.gap_fill_days_on_start > 0 {
|
if self.cfg.gap_fill_days_on_start > 0 {
|
||||||
let days = Some(self.cfg.gap_fill_days_on_start);
|
let days = Some(self.cfg.gap_fill_days_on_start);
|
||||||
|
let stations = self.stations();
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
r = rid::fill_gaps(&self.rid, &self.pool, &self.stations, days, bangkok_now()) => {
|
r = rid::fill_gaps(&self.rid, &self.pool, &stations, days, bangkok_now()) => {
|
||||||
if let Err(e) = r { tracing::error!("startup gap fill failed: {e:#}") }
|
if let Err(e) = r { tracing::error!("startup gap fill failed: {e:#}") }
|
||||||
}
|
}
|
||||||
_ = &mut shutdown => return Ok(()),
|
_ = &mut *shutdown => return Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +227,7 @@ impl Collector {
|
|||||||
let start = bangkok_now();
|
let start = bangkok_now();
|
||||||
self.write_status(&stats, true);
|
self.write_status(&stats, true);
|
||||||
let outcome: CycleOutcome =
|
let outcome: CycleOutcome =
|
||||||
rid::run_cycle(&self.rid, &self.pool, &self.stations, start).await;
|
rid::run_cycle(&self.rid, &self.pool, &self.stations(), start).await;
|
||||||
stats.total_runs += 1;
|
stats.total_runs += 1;
|
||||||
stats.last_run = Some(start);
|
stats.last_run = Some(start);
|
||||||
if outcome.current {
|
if outcome.current {
|
||||||
@@ -217,7 +276,8 @@ impl Collector {
|
|||||||
.max(std::time::Duration::from_secs(30));
|
.max(std::time::Duration::from_secs(30));
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = tokio::time::sleep(wait) => {}
|
_ = tokio::time::sleep(wait) => {}
|
||||||
_ = &mut shutdown => {
|
_ = self.trigger.notified() => tracing::info!("collection triggered via the API"),
|
||||||
|
_ = &mut *shutdown => {
|
||||||
tracing::info!("shutdown signal received");
|
tracing::info!("shutdown signal received");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -231,7 +291,7 @@ impl Collector {
|
|||||||
let now = bangkok_now();
|
let now = bangkok_now();
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
db::ensure_schema(&self.pool).await?;
|
db::ensure_schema(&self.pool).await?;
|
||||||
let o = rid::run_cycle(&self.rid, &self.pool, &self.stations, now).await;
|
let o = rid::run_cycle(&self.rid, &self.pool, &self.stations(), now).await;
|
||||||
println!(
|
println!(
|
||||||
"RID: reports {:?}, {} fetched, {} saved, latest {:?}, current hour in: {}{}",
|
"RID: reports {:?}, {} fetched, {} saved, latest {:?}, current hour in: {}{}",
|
||||||
o.dates,
|
o.dates,
|
||||||
@@ -348,7 +408,9 @@ impl Collector {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let Some(notifier) = forecasting::notifier(&self.cfg, self.http.clone()) else {
|
let Some(notifier) = forecasting::notifier(&self.cfg, self.http.clone()) else {
|
||||||
bail!("ntfy is off: NTFY_SERVER is not set");
|
bail!(
|
||||||
|
"notifications are off: neither NTFY_SERVER nor MATRIX_ACCESS_TOKEN/MATRIX_ROOM_ID is set"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
let mut store = ModelStore::new(&self.cfg.models_dir);
|
let mut store = ModelStore::new(&self.cfg.models_dir);
|
||||||
let models = store.refresh()?;
|
let models = store.refresh()?;
|
||||||
@@ -370,15 +432,32 @@ impl Collector {
|
|||||||
db::ensure_schema(&self.pool).await?;
|
db::ensure_schema(&self.pool).await?;
|
||||||
if dry_run {
|
if dry_run {
|
||||||
let states = db::notification_states(&self.pool).await?;
|
let states = db::notification_states(&self.pool).await?;
|
||||||
let plan =
|
let mut plan = vec![];
|
||||||
notify::evaluate(&readings, &snap.forecasts, &states, ¬ifier.settings, now);
|
if notifier.ntfy.is_some() {
|
||||||
|
plan.extend(
|
||||||
|
notify::evaluate(&readings, &snap.forecasts, &states, ¬ifier.settings, now)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p.notification.topic.clone(), p)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if notifier.matrix.is_some() {
|
||||||
|
let mstates = notifier
|
||||||
|
.matrix_states(&self.pool, &states, now, false)
|
||||||
|
.await?;
|
||||||
|
plan.extend(
|
||||||
|
notifier
|
||||||
|
.matrix_plan(&readings, &snap.forecasts, &mstates, now)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| ("matrix".to_string(), p)),
|
||||||
|
);
|
||||||
|
}
|
||||||
if plan.is_empty() {
|
if plan.is_empty() {
|
||||||
println!("no transitions: nothing would be published");
|
println!("no transitions: nothing would be published");
|
||||||
}
|
}
|
||||||
for p in plan {
|
for (to, p) in plan {
|
||||||
println!(
|
println!(
|
||||||
"would publish {}: {} -- {}",
|
"would publish {to}: {} -- {}",
|
||||||
p.notification.topic, p.notification.title, p.notification.message
|
p.notification.title, p.notification.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
//! else the defaults bundled in src/data/stations.json.
|
//! else the defaults bundled in src/data/stations.json.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
const BUNDLED: &str = include_str!("../../../src/data/stations.json");
|
const BUNDLED: &str = include_str!("../../../src/data/stations.json");
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct StationInfo {
|
pub struct StationInfo {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub thai_name: String,
|
pub thai_name: String,
|
||||||
@@ -55,6 +55,46 @@ impl Stations {
|
|||||||
Ok(Stations(map))
|
Ok(Stations(map))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&u32, &StationInfo)> {
|
||||||
|
self.0.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info(&self, n: u32) -> Option<&StationInfo> {
|
||||||
|
self.0.get(&n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info_mut(&mut self, n: u32) -> Option<&mut StationInfo> {
|
||||||
|
self.0.get_mut(&n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id a new station gets (the station CRUD API): one past the highest.
|
||||||
|
pub fn next_id(&self) -> u32 {
|
||||||
|
self.0.keys().max().map_or(1, |m| m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, n: u32, info: StationInfo) {
|
||||||
|
self.0.insert(n, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, n: u32) -> Option<StationInfo> {
|
||||||
|
self.0.remove(&n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist to the runtime file as the Python scraper's `save_stations`
|
||||||
|
/// (indent 2, UTF-8, trailing newline), atomically.
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
// numeric key order ("10" after "9"), as the Python mapping kept it
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
for (k, v) in &self.0 {
|
||||||
|
obj.insert(k.to_string(), serde_json::to_value(v)?);
|
||||||
|
}
|
||||||
|
let text = serde_json::to_string_pretty(&serde_json::Value::Object(obj))? + "\n";
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
|
||||||
|
std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Metadata for report column `n`, with the Python fallback for unknown
|
/// Metadata for report column `n`, with the Python fallback for unknown
|
||||||
/// columns (`P.{19+n}` / `Station n`).
|
/// columns (`P.{19+n}` / `Station n`).
|
||||||
pub fn get(&self, n: u32) -> StationInfo {
|
pub fn get(&self, n: u32) -> StationInfo {
|
||||||
@@ -81,4 +121,28 @@ mod tests {
|
|||||||
assert_eq!(s.get(8).code, "P.1");
|
assert_eq!(s.get(8).code, "P.1");
|
||||||
assert_eq!(s.get(99).code, "P.118"); // Python fallback
|
assert_eq!(s.get(99).code, "P.118"); // Python fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_round_trips() {
|
||||||
|
let mut s = Stations::bundled();
|
||||||
|
let id = s.next_id();
|
||||||
|
assert_eq!(id, 17);
|
||||||
|
let mut extra = s.get(8);
|
||||||
|
extra.code = "P.999".into();
|
||||||
|
s.insert(id, extra);
|
||||||
|
let dir = std::env::temp_dir().join(format!("ping-stations-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("stations.json");
|
||||||
|
s.save(&path).unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
text.starts_with("{\n \"1\": {\n \"code\": \"P.20\","),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("\"thai_name\": \"บ้านเชียงดาว\""));
|
||||||
|
let back = Stations::load(&path).unwrap();
|
||||||
|
assert_eq!(back.info(17).unwrap().code, "P.999");
|
||||||
|
assert_eq!(back.iter().count(), 17);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! Stale-while-revalidate response cache, as `_cached_swr` in src/web_api.py:
|
||||||
|
//!
|
||||||
|
//! - fresh entry: served;
|
||||||
|
//! - expired entry (no older than `max_stale`): served at once, marked stale,
|
||||||
|
//! and ONE background refresh is started;
|
||||||
|
//! - no entry: computed inline, single-flight per key (concurrent misses
|
||||||
|
//! wait for the one computation instead of piling queries onto Postgres);
|
||||||
|
//! - a failed computation serves the last good value, whatever its age;
|
||||||
|
//! - empty results are not cached, so recovery is immediate;
|
||||||
|
//! - a byte budget per cache: the oldest entries go first (query parameters
|
||||||
|
//! are client-controlled, so keys are unbounded, and one history response
|
||||||
|
//! can be 10 MB).
|
||||||
|
|
||||||
|
use super::error::ApiError;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// The size of a cached value, for the budget.
|
||||||
|
pub trait Weigh {
|
||||||
|
fn weight(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Weigh for axum::body::Bytes {
|
||||||
|
fn weight(&self) -> usize {
|
||||||
|
self.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Weigh for (axum::body::Bytes, bool) {
|
||||||
|
fn weight(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Weigh for u32 {
|
||||||
|
fn weight(&self) -> usize {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Swr<T> {
|
||||||
|
budget: usize,
|
||||||
|
entries: Mutex<HashMap<String, (Instant, T)>>,
|
||||||
|
flights: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||||
|
refreshing: Mutex<HashSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a computation returns: the value and whether to keep it.
|
||||||
|
pub type Computed<T> = Result<(T, bool), ApiError>;
|
||||||
|
|
||||||
|
impl<T: Clone + Send + Sync + Weigh + 'static> Swr<T> {
|
||||||
|
/// A cache holding at most `budget` bytes.
|
||||||
|
pub fn new(budget: usize) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
budget,
|
||||||
|
entries: Mutex::new(HashMap::new()),
|
||||||
|
flights: Mutex::new(HashMap::new()),
|
||||||
|
refreshing: Mutex::new(HashSet::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(&self, key: &str) -> Option<(Instant, T)> {
|
||||||
|
self.entries.lock().unwrap().get(key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(value, served_stale)`
|
||||||
|
pub async fn get<F, Fut>(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
key: String,
|
||||||
|
ttl: Duration,
|
||||||
|
max_stale: Option<Duration>,
|
||||||
|
compute: F,
|
||||||
|
) -> Result<(T, bool), ApiError>
|
||||||
|
where
|
||||||
|
F: Fn() -> Fut + Send + Sync + 'static,
|
||||||
|
Fut: Future<Output = Computed<T>> + Send + 'static,
|
||||||
|
{
|
||||||
|
if let Some((at, v)) = self.entry(&key) {
|
||||||
|
let age = at.elapsed();
|
||||||
|
if age < ttl {
|
||||||
|
return Ok((v, false));
|
||||||
|
}
|
||||||
|
if max_stale.is_none_or(|m| age <= m) {
|
||||||
|
let start = self.refreshing.lock().unwrap().insert(key.clone());
|
||||||
|
if start {
|
||||||
|
let me = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = me.compute(&key, ttl, &compute).await {
|
||||||
|
tracing::warn!("{key}: background refresh failed: {}", e.detail);
|
||||||
|
}
|
||||||
|
me.refreshing.lock().unwrap().remove(&key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok((v, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.compute(&key, ttl, &compute).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute<F, Fut>(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
ttl: Duration,
|
||||||
|
compute: &F,
|
||||||
|
) -> Result<(T, bool), ApiError>
|
||||||
|
where
|
||||||
|
F: Fn() -> Fut,
|
||||||
|
Fut: Future<Output = Computed<T>>,
|
||||||
|
{
|
||||||
|
let flight = self
|
||||||
|
.flights
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(key.to_string())
|
||||||
|
.or_default()
|
||||||
|
.clone();
|
||||||
|
let _guard = flight.lock().await;
|
||||||
|
let previous = self.entry(key);
|
||||||
|
if let Some((at, v)) = &previous
|
||||||
|
&& at.elapsed() < ttl
|
||||||
|
{
|
||||||
|
return Ok((v.clone(), false));
|
||||||
|
}
|
||||||
|
match compute().await {
|
||||||
|
Ok((v, keep)) => {
|
||||||
|
if keep && v.weight() <= self.budget {
|
||||||
|
let mut entries = self.entries.lock().unwrap();
|
||||||
|
entries.insert(key.to_string(), (Instant::now(), v.clone()));
|
||||||
|
let mut total: usize = entries.values().map(|(_, v)| v.weight()).sum();
|
||||||
|
if total > self.budget {
|
||||||
|
let mut ages: Vec<(Instant, String)> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(k, (at, _))| (*at, k.clone()))
|
||||||
|
.collect();
|
||||||
|
ages.sort();
|
||||||
|
for (_, k) in ages {
|
||||||
|
if total <= self.budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some((_, old)) = entries.remove(&k) {
|
||||||
|
total -= old.weight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((v, false))
|
||||||
|
}
|
||||||
|
Err(e) => match previous {
|
||||||
|
Some((at, v)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"{key}: recompute failed ({}); serving {}s-stale copy",
|
||||||
|
e.detail,
|
||||||
|
at.elapsed().as_secs()
|
||||||
|
);
|
||||||
|
Ok((v, true))
|
||||||
|
}
|
||||||
|
None => Err(e),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fresh_stale_and_error_fallback() {
|
||||||
|
let cache: Arc<Swr<u32>> = Swr::new(1024);
|
||||||
|
let calls = Arc::new(AtomicU32::new(0));
|
||||||
|
let fail = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let compute = {
|
||||||
|
let (calls, fail) = (calls.clone(), fail.clone());
|
||||||
|
move || {
|
||||||
|
let (calls, fail) = (calls.clone(), fail.clone());
|
||||||
|
async move {
|
||||||
|
let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
if fail.load(Ordering::SeqCst) {
|
||||||
|
Err(ApiError::new(500, "down"))
|
||||||
|
} else {
|
||||||
|
Ok((n, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let ttl = Duration::from_millis(40);
|
||||||
|
let key = || "k".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
cache.get(key(), ttl, None, compute.clone()).await.unwrap(),
|
||||||
|
(1, false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache.get(key(), ttl, None, compute.clone()).await.unwrap(),
|
||||||
|
(1, false)
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||||
|
// expired: the old value at once, refreshed in the background
|
||||||
|
assert_eq!(
|
||||||
|
cache.get(key(), ttl, None, compute.clone()).await.unwrap(),
|
||||||
|
(1, true)
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
assert_eq!(
|
||||||
|
cache.get(key(), ttl, None, compute.clone()).await.unwrap(),
|
||||||
|
(2, false)
|
||||||
|
);
|
||||||
|
// older than max_stale and the database is down: last good value
|
||||||
|
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||||
|
fail.store(true, Ordering::SeqCst);
|
||||||
|
let max = Some(Duration::from_millis(1));
|
||||||
|
assert_eq!(
|
||||||
|
cache.get(key(), ttl, max, compute.clone()).await.unwrap(),
|
||||||
|
(2, true)
|
||||||
|
);
|
||||||
|
// nothing cached yet: the error
|
||||||
|
assert!(cache.get("other".into(), ttl, max, compute).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn budget_evicts_the_oldest() {
|
||||||
|
let cache: Arc<Swr<u32>> = Swr::new(8); // two u32 entries
|
||||||
|
let ttl = Duration::from_secs(60);
|
||||||
|
for (i, k) in ["a", "b", "c"].iter().enumerate() {
|
||||||
|
let v = i as u32;
|
||||||
|
cache
|
||||||
|
.get(
|
||||||
|
k.to_string(),
|
||||||
|
ttl,
|
||||||
|
None,
|
||||||
|
move || async move { Ok((v, true)) },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||||
|
}
|
||||||
|
let entries = cache.entries.lock().unwrap();
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert!(!entries.contains_key("a"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
//! RID gauge readings: /measurements/*, /api/stats.
|
||||||
|
|
||||||
|
use super::error::{ApiError, ApiResult, ok, to_body};
|
||||||
|
use super::params::{self, Param};
|
||||||
|
use super::pyfmt::{end_of_day, isoformat, py_round, start_of_day};
|
||||||
|
use super::{Shared, secs};
|
||||||
|
use crate::util::bangkok_now;
|
||||||
|
use axum::extract::{Path, RawQuery, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use chrono::{NaiveDateTime, TimeDelta};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sqlx::postgres::PgRow;
|
||||||
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A result row as a JSON object: column names as keys (SQL aliases), in
|
||||||
|
/// select order. NUMERIC columns must be cast to float8 in the query (as
|
||||||
|
/// Python's `float(Decimal)`), timestamps become `isoformat()` strings.
|
||||||
|
pub fn row_json(row: &PgRow) -> Map<String, Value> {
|
||||||
|
let mut m = Map::new();
|
||||||
|
for (i, col) in row.columns().iter().enumerate() {
|
||||||
|
let v = match col.type_info().name() {
|
||||||
|
"INT2" => json!(row.try_get::<Option<i16>, _>(i).ok().flatten()),
|
||||||
|
"INT4" => json!(row.try_get::<Option<i32>, _>(i).ok().flatten()),
|
||||||
|
"INT8" => json!(row.try_get::<Option<i64>, _>(i).ok().flatten()),
|
||||||
|
"FLOAT4" => super::pyfmt::num(
|
||||||
|
row.try_get::<Option<f32>, _>(i)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(f64::from),
|
||||||
|
),
|
||||||
|
"FLOAT8" => super::pyfmt::num(row.try_get::<Option<f64>, _>(i).ok().flatten()),
|
||||||
|
"BOOL" => json!(row.try_get::<Option<bool>, _>(i).ok().flatten()),
|
||||||
|
"TIMESTAMP" => json!(
|
||||||
|
row.try_get::<Option<NaiveDateTime>, _>(i)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(isoformat)
|
||||||
|
),
|
||||||
|
"DATE" => json!(
|
||||||
|
row.try_get::<Option<chrono::NaiveDate>, _>(i)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||||
|
),
|
||||||
|
_ => json!(row.try_get::<Option<String>, _>(i).ok().flatten()),
|
||||||
|
};
|
||||||
|
m.insert(col.name().to_string(), v);
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rows_json(rows: &[PgRow]) -> Vec<Value> {
|
||||||
|
rows.iter().map(|r| Value::Object(row_json(r))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query(q: &Option<String>) -> std::collections::HashMap<String, String> {
|
||||||
|
params::query_map(q.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `datetime.now() - timedelta(hours=h)`; `None` where Python overflows.
|
||||||
|
pub fn hours_before(t: NaiveDateTime, hours: i64) -> Option<NaiveDateTime> {
|
||||||
|
t.checked_sub_signed(TimeDelta::try_hours(hours)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEASUREMENT_COLUMNS: &str = "m.timestamp, s.station_code, s.english_name AS station_name_en,
|
||||||
|
s.thai_name AS station_name_th, m.water_level::float8 AS water_level,
|
||||||
|
m.discharge::float8 AS discharge, m.discharge_percent::float8 AS discharge_percent, m.status";
|
||||||
|
|
||||||
|
pub const LATEST: &[Param] = &[Param::int("limit", 100)];
|
||||||
|
|
||||||
|
/// `SQLAdapter.get_latest_measurements`: the newest reading per station.
|
||||||
|
pub async fn latest_rows(pool: &sqlx::PgPool, limit: i64) -> Result<Vec<PgRow>, sqlx::Error> {
|
||||||
|
if limit < 0 {
|
||||||
|
// Postgres refuses a negative LIMIT; the Python adapter answered []
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT {MEASUREMENT_COLUMNS}
|
||||||
|
FROM water_measurements m
|
||||||
|
JOIN stations s ON m.station_id = s.id
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT station_id, MAX(timestamp) as max_timestamp
|
||||||
|
FROM water_measurements
|
||||||
|
GROUP BY station_id
|
||||||
|
) latest ON m.station_id = latest.station_id AND m.timestamp = latest.max_timestamp
|
||||||
|
ORDER BY s.station_code
|
||||||
|
LIMIT $1"
|
||||||
|
))
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /measurements/latest: the most frequently hit endpoint (every
|
||||||
|
/// dashboard poll), stale-while-revalidate cached.
|
||||||
|
pub async fn latest(State(st): State<Shared>, RawQuery(q): RawQuery, h: HeaderMap) -> ApiResult {
|
||||||
|
st.metrics.count("measurements_latest");
|
||||||
|
let a = params::parse(LATEST, &query(&q), &[], &h)?;
|
||||||
|
let limit = a.int("limit");
|
||||||
|
let pool = st.pool.clone();
|
||||||
|
let (body, stale) = st
|
||||||
|
.caches
|
||||||
|
.latest
|
||||||
|
.get(
|
||||||
|
format!("latest:{limit}"),
|
||||||
|
secs(st.cfg.web.latest_cache_ttl_s),
|
||||||
|
None,
|
||||||
|
move || {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
let rows = rows_json(&latest_rows(&pool, limit).await?);
|
||||||
|
let keep = !rows.is_empty();
|
||||||
|
Ok((to_body(&rows), keep))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ok(body, stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryRow = (NaiveDateTime, String, Option<f64>, Option<f64>, Option<f64>);
|
||||||
|
|
||||||
|
pub const HISTORY: &[Param] = &[
|
||||||
|
Param::path_str("station_code"),
|
||||||
|
Param::int("hours", 168).ge(1),
|
||||||
|
Param::int("limit", 50000).ge(1).le(100000),
|
||||||
|
Param::opt_date("start", "First day (overrides hours)"),
|
||||||
|
Param::opt_date("end", "Last day, inclusive"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// GET /measurements/history/{station_code} (`PostgresHistory.station_history`)
|
||||||
|
pub async fn history(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
RawQuery(q): RawQuery,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
let a = params::parse(HISTORY, &query(&q), &[("station_code", &code)], &h)?;
|
||||||
|
let key = format!(
|
||||||
|
"{code}:{}:{}:{}:{}",
|
||||||
|
a.py_str("hours"),
|
||||||
|
a.py_str("limit"),
|
||||||
|
a.py_str("start"),
|
||||||
|
a.py_str("end")
|
||||||
|
);
|
||||||
|
let (hours, limit, start, end) = (
|
||||||
|
a.int("hours"),
|
||||||
|
a.int("limit"),
|
||||||
|
a.date("start"),
|
||||||
|
a.date("end"),
|
||||||
|
);
|
||||||
|
let pool = st.pool.clone();
|
||||||
|
let unavailable = || ApiError::new(502, "Measurement history unavailable");
|
||||||
|
let (body, stale) = st
|
||||||
|
.caches
|
||||||
|
.history
|
||||||
|
.get(
|
||||||
|
key,
|
||||||
|
Duration::from_secs(300),
|
||||||
|
Some(Duration::ZERO),
|
||||||
|
move || {
|
||||||
|
let (pool, code) = (pool.clone(), code.clone());
|
||||||
|
async move {
|
||||||
|
let end_time = end.map_or_else(bangkok_now, end_of_day);
|
||||||
|
let start_time = match start {
|
||||||
|
Some(d) => start_of_day(d),
|
||||||
|
None => hours_before(end_time, hours).ok_or_else(unavailable)?,
|
||||||
|
};
|
||||||
|
if start_time >= end_time {
|
||||||
|
return Err(unavailable());
|
||||||
|
}
|
||||||
|
let rows: Vec<HistoryRow> = sqlx::query_as(
|
||||||
|
"SELECT m.timestamp, s.station_code, m.water_level::float8,
|
||||||
|
m.discharge::float8, m.discharge_percent::float8
|
||||||
|
FROM water_measurements m
|
||||||
|
JOIN stations s ON m.station_id = s.id
|
||||||
|
WHERE s.station_code = $1
|
||||||
|
AND m.timestamp >= $2
|
||||||
|
AND m.timestamp <= $3
|
||||||
|
ORDER BY m.timestamp ASC
|
||||||
|
LIMIT $4",
|
||||||
|
)
|
||||||
|
.bind(&code)
|
||||||
|
.bind(start_time)
|
||||||
|
.bind(end_time)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Error fetching measurement history: {e}");
|
||||||
|
unavailable()
|
||||||
|
})?;
|
||||||
|
let out: Vec<Value> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(t, code, level, discharge, pct)| {
|
||||||
|
// no rating curves are defined: Q = 20 * H, as postgres_history.py
|
||||||
|
let discharge = discharge.or(level.map(|h| py_round(20.0 * h, 2)));
|
||||||
|
json!({
|
||||||
|
"timestamp": isoformat(t),
|
||||||
|
"station_code": code,
|
||||||
|
"water_level": level,
|
||||||
|
"discharge": discharge,
|
||||||
|
"discharge_percent": pct,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((to_body(&out), true))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ok(body, stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const STATION: &[Param] = &[
|
||||||
|
Param::path_str("station_code"),
|
||||||
|
Param::int("hours", 24),
|
||||||
|
Param::int("limit", 1000),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// GET /measurements/station/{station_code}: newest first.
|
||||||
|
pub async fn station(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
RawQuery(q): RawQuery,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
st.metrics.count("measurements_station");
|
||||||
|
let a = params::parse(STATION, &query(&q), &[("station_code", &code)], &h)?;
|
||||||
|
let end = bangkok_now();
|
||||||
|
let start = hours_before(end, a.int("hours"))
|
||||||
|
.ok_or_else(|| ApiError::new(500, "date value out of range"))?;
|
||||||
|
let rows = sqlx::query(&format!(
|
||||||
|
"SELECT {MEASUREMENT_COLUMNS}
|
||||||
|
FROM water_measurements m
|
||||||
|
JOIN stations s ON m.station_id = s.id
|
||||||
|
WHERE m.timestamp BETWEEN $1 AND $2 AND s.station_code IN ($3)
|
||||||
|
ORDER BY m.timestamp DESC, s.station_code"
|
||||||
|
))
|
||||||
|
.bind(start)
|
||||||
|
.bind(end)
|
||||||
|
.bind(&code)
|
||||||
|
.fetch_all(&st.pool)
|
||||||
|
.await?;
|
||||||
|
let mut out = rows_json(&rows);
|
||||||
|
// measurements[:limit], Python slice semantics
|
||||||
|
let limit = a.int("limit");
|
||||||
|
let keep = if limit >= 0 {
|
||||||
|
(limit as usize).min(out.len())
|
||||||
|
} else {
|
||||||
|
out.len().saturating_sub(limit.unsigned_abs() as usize)
|
||||||
|
};
|
||||||
|
out.truncate(keep);
|
||||||
|
ok(to_body(&out), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count(pool: &sqlx::PgPool, table: &str) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>(&format!("SELECT COUNT(*) FROM {table}"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HiiTotals = (
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
Option<NaiveDateTime>,
|
||||||
|
Option<NaiveDateTime>,
|
||||||
|
Option<NaiveDateTime>,
|
||||||
|
Option<NaiveDateTime>,
|
||||||
|
);
|
||||||
|
|
||||||
|
async fn compute_stats(pool: sqlx::PgPool) -> super::cache::Computed<axum::body::Bytes> {
|
||||||
|
// Heavy: full-table counts and coverage; runs at most once per TTL
|
||||||
|
type Stats = (i64, i64, Option<NaiveDateTime>, Option<NaiveDateTime>, i64);
|
||||||
|
let (total, stations, first, last, recorded): Stats = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*), COUNT(DISTINCT station_id), MIN(timestamp), MAX(timestamp),
|
||||||
|
COUNT(DISTINCT TO_CHAR(timestamp, 'YYYY-MM-DD HH24'))
|
||||||
|
FROM water_measurements",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
let (Some(mut first_ts), Some(mut last_ts)) = (first, last) else {
|
||||||
|
return Err(ApiError::new(503, "Database statistics unavailable"));
|
||||||
|
};
|
||||||
|
if total == 0 {
|
||||||
|
return Err(ApiError::new(503, "Database statistics unavailable"));
|
||||||
|
}
|
||||||
|
let slot = crate::util::floor_hour;
|
||||||
|
let expected = (slot(last_ts) - slot(first_ts)).num_hours() + 1;
|
||||||
|
let coverage = py_round(100.0 * recorded as f64 / expected as f64, 1);
|
||||||
|
|
||||||
|
let hii: Option<HiiTotals> = sqlx::query_as(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM hii_rainfall),
|
||||||
|
(SELECT COUNT(*) FROM hii_waterlevel),
|
||||||
|
(SELECT COUNT(*) FROM hii_rain_stations),
|
||||||
|
(SELECT COUNT(*) FROM hii_wl_stations),
|
||||||
|
(SELECT MIN(timestamp) FROM hii_rainfall),
|
||||||
|
(SELECT MAX(timestamp) FROM hii_rainfall),
|
||||||
|
(SELECT MIN(timestamp) FROM hii_waterlevel),
|
||||||
|
(SELECT MAX(timestamp) FROM hii_waterlevel)",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| tracing::warn!("HII stats unavailable: {e}"))
|
||||||
|
.ok();
|
||||||
|
let openmeteo_n = count(&pool, "openmeteo_rain").await;
|
||||||
|
let reservoir_n = count(&pool, "rid_reservoir_daily").await;
|
||||||
|
let (mut rain_n, mut wl_n, mut hii_stations) = (0, 0, 0);
|
||||||
|
if let Some((rn, wn, rs, ws, rain_lo, rain_hi, wl_lo, wl_hi)) = hii {
|
||||||
|
(rain_n, wl_n, hii_stations) = (rn, wn, rs + ws);
|
||||||
|
for lo in [rain_lo, wl_lo].into_iter().flatten() {
|
||||||
|
if lo < first_ts {
|
||||||
|
first_ts = lo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for hi in [rain_hi, wl_hi].into_iter().flatten() {
|
||||||
|
if hi > last_ts {
|
||||||
|
last_ts = hi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body = json!({
|
||||||
|
// whole-DB totals (RID + HII + Open-Meteo + dams), breakdown alongside
|
||||||
|
"total_measurements": total + rain_n + wl_n + openmeteo_n + reservoir_n,
|
||||||
|
"rid_measurements": total,
|
||||||
|
"hii_rainfall_measurements": rain_n,
|
||||||
|
"hii_waterlevel_measurements": wl_n,
|
||||||
|
"openmeteo_rain_measurements": openmeteo_n,
|
||||||
|
"reservoir_measurements": reservoir_n,
|
||||||
|
"station_count": stations + hii_stations,
|
||||||
|
"rid_station_count": stations,
|
||||||
|
"hii_station_count": hii_stations,
|
||||||
|
"first_timestamp": isoformat(first_ts),
|
||||||
|
"last_timestamp": isoformat(last_ts),
|
||||||
|
"days_spanned": (last_ts.date() - first_ts.date()).num_days() + 1,
|
||||||
|
"coverage_percent": coverage,
|
||||||
|
});
|
||||||
|
Ok((to_body(&body), true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/stats: database coverage (totals, date range, hourly coverage).
|
||||||
|
pub async fn stats(State(st): State<Shared>) -> ApiResult {
|
||||||
|
st.metrics.count("api_stats");
|
||||||
|
let pool = st.pool.clone();
|
||||||
|
let (body, _) = st
|
||||||
|
.caches
|
||||||
|
.stats
|
||||||
|
.get("all".into(), Duration::from_secs(300), None, move || {
|
||||||
|
compute_stats(pool.clone())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
ok(body, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! FastAPI-shaped errors (`{"detail": ...}`) and JSON responses.
|
||||||
|
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub status: StatusCode,
|
||||||
|
pub detail: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
pub fn new(status: u16, detail: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
detail: Value::String(detail.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 422 with pydantic's error list.
|
||||||
|
pub fn validation(errors: Vec<Value>) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
detail: Value::Array(errors),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unexpected failure (FastAPI handlers answer `500 str(e)`).
|
||||||
|
pub fn internal(e: impl std::fmt::Display) -> Self {
|
||||||
|
Self::new(500, e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for ApiError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
tracing::error!("database: {e}");
|
||||||
|
Self::internal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for ApiError {
|
||||||
|
fn from(e: anyhow::Error) -> Self {
|
||||||
|
tracing::error!("{e:#}");
|
||||||
|
Self::new(500, format!("{e:#}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
json_response(
|
||||||
|
self.status,
|
||||||
|
to_body(&json!({ "detail": self.detail })),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ApiResult = Result<Response, ApiError>;
|
||||||
|
|
||||||
|
pub fn to_body(v: &impl Serialize) -> Bytes {
|
||||||
|
Bytes::from(serde_json::to_vec(v).expect("JSON-serializable value"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JSON body; `stale` adds `X-Data-Stale: true` (served from the cache
|
||||||
|
/// after a failed refresh, or an old stored forecast).
|
||||||
|
pub fn json_response(status: StatusCode, body: Bytes, stale: bool) -> Response {
|
||||||
|
let mut r = (status, body).into_response();
|
||||||
|
let h = r.headers_mut();
|
||||||
|
h.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
if stale {
|
||||||
|
h.insert("x-data-stale", HeaderValue::from_static("true"));
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ok(body: Bytes, stale: bool) -> ApiResult {
|
||||||
|
Ok(json_response(StatusCode::OK, body, stale))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ok_json(v: &impl Serialize) -> ApiResult {
|
||||||
|
ok(to_body(v), false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! Flood forecasts: /forecast, /api/forecast/*, /api/notifications.
|
||||||
|
|
||||||
|
use super::data::{hours_before, rows_json};
|
||||||
|
use super::error::{ApiError, ApiResult, ok, ok_json, to_body};
|
||||||
|
use super::params::{self, Param};
|
||||||
|
use super::pyfmt::{end_of_day, isoformat, py_round, start_of_day};
|
||||||
|
use super::{Shared, secs};
|
||||||
|
use crate::util::bangkok_now;
|
||||||
|
use axum::extract::{Path, RawQuery, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// GET /forecast: flood-risk forecasts per station for the 6/12/24 h
|
||||||
|
/// horizons, as the forecaster last stored them (forecast_current).
|
||||||
|
///
|
||||||
|
/// The Python app recomputed in-process when the stored forecast was older
|
||||||
|
/// than FORECAST_MAX_AGE_SECONDS; here the forecaster runs in the same
|
||||||
|
/// process, so an old forecast is served marked `X-Data-Stale: true`.
|
||||||
|
pub async fn forecast(State(st): State<Shared>) -> ApiResult {
|
||||||
|
st.metrics.count("forecast");
|
||||||
|
let pool = st.pool.clone();
|
||||||
|
let max_age = st.cfg.web.forecast_max_age_s as i64;
|
||||||
|
let ((body, old), _) = st
|
||||||
|
.caches
|
||||||
|
.forecast
|
||||||
|
.get(
|
||||||
|
"stored".into(),
|
||||||
|
Duration::from_secs(60),
|
||||||
|
Some(Duration::ZERO),
|
||||||
|
move || {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
let row: Option<(NaiveDateTime, String)> = sqlx::query_as(
|
||||||
|
"SELECT computed_at, payload FROM forecast_current WHERE id = 'all'",
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
let Some((computed_at, payload)) = row else {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
503,
|
||||||
|
"No forecast stored yet (the forecaster has not run)",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let age = (bangkok_now() - computed_at).num_seconds();
|
||||||
|
if age > max_age {
|
||||||
|
tracing::warn!("stored forecast is {:.1} h old", age as f64 / 3600.0);
|
||||||
|
}
|
||||||
|
Ok(((payload.into(), age > max_age), true))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ok(body, old)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const HISTORY: &[Param] = &[
|
||||||
|
Param::path_str("station_code"),
|
||||||
|
Param::int("hours", 168).ge(1),
|
||||||
|
Param::opt_date("start", "First day (overrides hours)"),
|
||||||
|
Param::opt_date("end", "Last day, inclusive"),
|
||||||
|
Param::opt_int("horizon").ge(1).le(48),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// GET /api/forecast/history/{station_code}: issued model forecasts, what
|
||||||
|
/// the model predicted when (`ForecastHistoryStore.fetch`).
|
||||||
|
pub async fn history(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
RawQuery(q): RawQuery,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
st.metrics.count("forecast_history");
|
||||||
|
let a = params::parse(
|
||||||
|
HISTORY,
|
||||||
|
¶ms::query_map(q.as_deref()),
|
||||||
|
&[("station_code", &code)],
|
||||||
|
&h,
|
||||||
|
)?;
|
||||||
|
let end = a.date("end").map_or_else(bangkok_now, end_of_day);
|
||||||
|
let start = match a.date("start") {
|
||||||
|
Some(d) => start_of_day(d),
|
||||||
|
None => hours_before(end, a.int("hours"))
|
||||||
|
.ok_or_else(|| ApiError::new(500, "date value out of range"))?,
|
||||||
|
};
|
||||||
|
let horizon = a.opt_int("horizon");
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT as_of, station_code, horizon_hours,
|
||||||
|
predicted_max_level::float8 AS predicted_max_level,
|
||||||
|
p_warning::float8 AS p_warning, p_danger::float8 AS p_danger,
|
||||||
|
current_level::float8 AS current_level, model_version, source
|
||||||
|
FROM forecast_history
|
||||||
|
WHERE station_code = $1 AND as_of >= $2 AND as_of <= $3
|
||||||
|
AND ($4::int IS NULL OR horizon_hours = $4)
|
||||||
|
ORDER BY as_of ASC, horizon_hours ASC LIMIT 5000",
|
||||||
|
)
|
||||||
|
.bind(&code)
|
||||||
|
.bind(start)
|
||||||
|
.bind(end)
|
||||||
|
.bind(horizon.map(|v| v as i32))
|
||||||
|
.fetch_all(&st.pool)
|
||||||
|
.await;
|
||||||
|
match rows {
|
||||||
|
Ok(rows) => ok_json(&rows_json(&rows)),
|
||||||
|
Err(e) => {
|
||||||
|
// as the Python store: an empty history rather than an error
|
||||||
|
tracing::error!("forecast history fetch failed: {e}");
|
||||||
|
ok_json(&Vec::<Value>::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/notifications: public ntfy settings for the dashboard's
|
||||||
|
/// subscribe links.
|
||||||
|
pub async fn notifications(State(st): State<Shared>) -> ApiResult {
|
||||||
|
let Some(server) = &st.cfg.ntfy_server else {
|
||||||
|
return ok_json(&json!({"enabled": false}));
|
||||||
|
};
|
||||||
|
let p = &st.cfg.ntfy_topic_prefix;
|
||||||
|
ok_json(&json!({
|
||||||
|
"enabled": true,
|
||||||
|
"server": server,
|
||||||
|
"prefix": p,
|
||||||
|
"topics": {
|
||||||
|
"warning": format!("{p}-warning"),
|
||||||
|
"danger": format!("{p}-danger"),
|
||||||
|
"p1_outlook": format!("{p}-p1-outlook"),
|
||||||
|
"status": format!("{p}-status"),
|
||||||
|
"station_pattern": format!("{p}-<station>-warning | {p}-<station>-danger (station code lowercase, no dot: p1, p103)"),
|
||||||
|
},
|
||||||
|
"semantics": "transitions only: one message on crossing up, one all-clear on the way down (0.10 m hysteresis)",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const SKILL: &[Param] = &[
|
||||||
|
Param::str("station_code", "P.1"),
|
||||||
|
Param::int("horizon", 24).ge(1).le(48),
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_VERIFIED: usize = 24; // fewer than a day of verified hours is not a number
|
||||||
|
|
||||||
|
struct Verified {
|
||||||
|
as_of: NaiveDateTime,
|
||||||
|
predicted: f64,
|
||||||
|
current: Option<f64>,
|
||||||
|
observed: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Python's `sum()` (3.11: left to right) / len
|
||||||
|
fn mean(v: impl Iterator<Item = f64>) -> f64 {
|
||||||
|
let (mut s, mut n) = (0.0, 0usize);
|
||||||
|
for x in v {
|
||||||
|
s += x;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
s / n as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `src/ml/skill.compute_skill`: issued forecasts verified against what the
|
||||||
|
/// river did, per model version, with a persistence baseline.
|
||||||
|
pub async fn compute_skill(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
code: &str,
|
||||||
|
horizon: i64,
|
||||||
|
now: NaiveDateTime,
|
||||||
|
) -> Result<Value, sqlx::Error> {
|
||||||
|
let verifiable_before = now - chrono::TimeDelta::hours(horizon);
|
||||||
|
type Row = (
|
||||||
|
NaiveDateTime,
|
||||||
|
Option<String>,
|
||||||
|
f64,
|
||||||
|
Option<f64>,
|
||||||
|
Option<f64>,
|
||||||
|
Option<i64>,
|
||||||
|
);
|
||||||
|
let rows: Vec<Row> = sqlx::query_as(
|
||||||
|
"SELECT f.as_of, f.model_version, f.predicted_max_level::float8, f.current_level::float8,
|
||||||
|
(SELECT MAX(m.water_level) FROM water_measurements m
|
||||||
|
JOIN stations s ON s.id = m.station_id
|
||||||
|
WHERE s.station_code = f.station_code
|
||||||
|
AND m.timestamp > f.as_of
|
||||||
|
AND m.timestamp <= f.as_of + (f.horizon_hours || ' hours')::interval)::float8,
|
||||||
|
(SELECT COUNT(m.water_level) FROM water_measurements m
|
||||||
|
JOIN stations s ON s.id = m.station_id
|
||||||
|
WHERE s.station_code = f.station_code
|
||||||
|
AND m.timestamp > f.as_of
|
||||||
|
AND m.timestamp <= f.as_of + (f.horizon_hours || ' hours')::interval)
|
||||||
|
FROM forecast_history f
|
||||||
|
WHERE f.station_code = $1 AND f.horizon_hours = $2
|
||||||
|
AND f.source = 'model' AND f.predicted_max_level IS NOT NULL
|
||||||
|
AND f.as_of <= $3
|
||||||
|
ORDER BY f.as_of",
|
||||||
|
)
|
||||||
|
.bind(code)
|
||||||
|
.bind(horizon as i32)
|
||||||
|
.bind(verifiable_before)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// versions in order of first appearance, as Python's dict
|
||||||
|
let mut by_version: Vec<(String, Vec<Verified>)> = vec![];
|
||||||
|
for (as_of, version, predicted, current, observed, n) in rows {
|
||||||
|
// most of the window must be observed, or the "max" is not the peak
|
||||||
|
let Some(observed) = observed else { continue };
|
||||||
|
if (n.unwrap_or(0) as f64) < horizon as f64 * 0.75 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let version = version.unwrap_or_else(|| "unknown".into());
|
||||||
|
let row = Verified {
|
||||||
|
as_of,
|
||||||
|
predicted,
|
||||||
|
current,
|
||||||
|
observed,
|
||||||
|
};
|
||||||
|
match by_version.iter_mut().find(|(v, _)| *v == version) {
|
||||||
|
Some((_, list)) => list.push(row),
|
||||||
|
None => by_version.push((version, vec![row])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut versions: Vec<Map<String, Value>> = vec![];
|
||||||
|
for (version, rows) in &by_version {
|
||||||
|
let err: Vec<f64> = rows.iter().map(|r| r.predicted - r.observed).collect();
|
||||||
|
let mae = mean(err.iter().map(|e| e.abs()));
|
||||||
|
let bias = mean(err.iter().copied());
|
||||||
|
let pers: Vec<(f64, f64)> = rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.current.map(|c| (c, r.observed)))
|
||||||
|
.collect();
|
||||||
|
let persistence = (!pers.is_empty()).then(|| mean(pers.iter().map(|(c, o)| (c - o).abs())));
|
||||||
|
let high: Vec<(f64, f64)> = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.observed >= 2.0)
|
||||||
|
.map(|r| (r.predicted, r.observed))
|
||||||
|
.collect();
|
||||||
|
let first = rows.iter().map(|r| r.as_of).min().expect("non-empty");
|
||||||
|
let last = rows.iter().map(|r| r.as_of).max().expect("non-empty");
|
||||||
|
let mut v = Map::new();
|
||||||
|
v.insert("model_version".into(), json!(version));
|
||||||
|
v.insert("first_issued".into(), json!(isoformat(first)));
|
||||||
|
v.insert("last_issued".into(), json!(isoformat(last)));
|
||||||
|
v.insert("n".into(), json!(rows.len()));
|
||||||
|
v.insert("mae_m".into(), json!(py_round(mae, 3)));
|
||||||
|
v.insert("bias_m".into(), json!(py_round(bias, 3)));
|
||||||
|
v.insert(
|
||||||
|
"persistence_mae_m".into(),
|
||||||
|
json!(persistence.map(|p| py_round(p, 3))),
|
||||||
|
);
|
||||||
|
v.insert(
|
||||||
|
"skill".into(),
|
||||||
|
json!(
|
||||||
|
persistence
|
||||||
|
.filter(|p| *p != 0.0)
|
||||||
|
.map(|p| py_round(1.0 - mae / p, 3))
|
||||||
|
),
|
||||||
|
);
|
||||||
|
v.insert("above_2m_n".into(), json!(high.len()));
|
||||||
|
v.insert(
|
||||||
|
"above_2m_mae_m".into(),
|
||||||
|
json!(
|
||||||
|
(!high.is_empty())
|
||||||
|
.then(|| py_round(mean(high.iter().map(|(p, o)| (p - o).abs())), 3))
|
||||||
|
),
|
||||||
|
);
|
||||||
|
v.insert("enough_data".into(), json!(rows.len() >= MIN_VERIFIED));
|
||||||
|
versions.push(v);
|
||||||
|
}
|
||||||
|
// stable sort on the ISO string, as Python
|
||||||
|
versions.sort_by(|a, b| a["first_issued"].as_str().cmp(&b["first_issued"].as_str()));
|
||||||
|
|
||||||
|
let current = versions.last().cloned();
|
||||||
|
let previous = versions
|
||||||
|
.len()
|
||||||
|
.checked_sub(1)
|
||||||
|
.and_then(|n| {
|
||||||
|
versions[..n]
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|v| v["enough_data"] == true)
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
let trend = match (¤t, &previous) {
|
||||||
|
(Some(c), Some(p)) if c["enough_data"] == true => {
|
||||||
|
let (cm, pm) = (c["mae_m"].as_f64().unwrap(), p["mae_m"].as_f64().unwrap());
|
||||||
|
let skill_delta = match (c["skill"].as_f64(), p["skill"].as_f64()) {
|
||||||
|
(Some(cs), Some(ps)) => json!(py_round(cs - ps, 3)),
|
||||||
|
_ => Value::Null,
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"previous_version": p["model_version"],
|
||||||
|
"mae_delta_m": py_round(cm - pm, 3),
|
||||||
|
"skill_delta": skill_delta,
|
||||||
|
"better": cm < pm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Value::Null,
|
||||||
|
};
|
||||||
|
Ok(json!({
|
||||||
|
"station_code": code,
|
||||||
|
"horizon_hours": horizon,
|
||||||
|
"verified_until": isoformat(verifiable_before),
|
||||||
|
"min_verified": MIN_VERIFIED,
|
||||||
|
"versions": versions,
|
||||||
|
"current": current,
|
||||||
|
"trend": trend,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/forecast/skill: is the model getting better?
|
||||||
|
pub async fn skill(State(st): State<Shared>, RawQuery(q): RawQuery, h: HeaderMap) -> ApiResult {
|
||||||
|
st.metrics.count("forecast_skill");
|
||||||
|
let a = params::parse(SKILL, ¶ms::query_map(q.as_deref()), &[], &h)?;
|
||||||
|
let (code, horizon) = (a.str("station_code").to_string(), a.int("horizon"));
|
||||||
|
let pool = st.pool.clone();
|
||||||
|
let (body, stale) = st
|
||||||
|
.caches
|
||||||
|
.hii
|
||||||
|
.get(
|
||||||
|
format!("skill:{code}:{horizon}"),
|
||||||
|
secs(st.cfg.web.hii_cache_ttl_s.max(900)),
|
||||||
|
None,
|
||||||
|
move || {
|
||||||
|
let (pool, code) = (pool.clone(), code.clone());
|
||||||
|
async move {
|
||||||
|
let v = compute_skill(&pool, &code, horizon, bangkok_now()).await?;
|
||||||
|
Ok((to_body(&v), true))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ok(body, stale)
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! HII/ThaiWater feeds: /api/hii/*, /sensors/thaiwater.
|
||||||
|
|
||||||
|
use super::data::{hours_before, rows_json};
|
||||||
|
use super::error::{ApiError, ApiResult, ok, to_body};
|
||||||
|
use super::params::{self, Param};
|
||||||
|
use super::pyfmt::{isoformat, num, numpy_mean, py_round, py_str};
|
||||||
|
use super::{Shared, secs};
|
||||||
|
use crate::util::{bangkok_now, floor_hour};
|
||||||
|
use axum::extract::{RawQuery, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use chrono::{NaiveDateTime, TimeDelta};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub const LATEST: &[Param] = &[Param::int("hours", 26).ge(1).le(168)];
|
||||||
|
|
||||||
|
const RAIN_SQL: &str = "
|
||||||
|
SELECT s.id AS station_id, s.oldcode, s.name_en, s.name_th,
|
||||||
|
s.latitude::float8 AS latitude, s.longitude::float8 AS longitude, s.agency,
|
||||||
|
m.timestamp, m.rain_1h::float8 AS rain_1h, m.rain_24h::float8 AS rain_24h
|
||||||
|
FROM hii_rainfall m
|
||||||
|
JOIN hii_rain_stations s ON s.id = m.station_id
|
||||||
|
JOIN (SELECT station_id, MAX(timestamp) AS latest_ts
|
||||||
|
FROM hii_rainfall WHERE timestamp >= $1
|
||||||
|
GROUP BY station_id) latest
|
||||||
|
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
|
||||||
|
ORDER BY s.id";
|
||||||
|
|
||||||
|
const WATERLEVEL_SQL: &str = "
|
||||||
|
SELECT s.id AS station_id, s.oldcode, s.rid_code, s.name_en, s.name_th,
|
||||||
|
s.latitude::float8 AS latitude, s.longitude::float8 AS longitude, s.agency,
|
||||||
|
s.river_name, s.offset_msl::float8 AS offset_msl,
|
||||||
|
s.min_bank_msl::float8 AS min_bank_msl, s.is_key_station,
|
||||||
|
m.timestamp, m.wl_msl::float8 AS wl_msl, m.discharge::float8 AS discharge,
|
||||||
|
m.storage_percent::float8 AS storage_percent, m.situation_level,
|
||||||
|
m.diff_wl_bank::float8 AS diff_wl_bank
|
||||||
|
FROM hii_waterlevel m
|
||||||
|
JOIN hii_wl_stations s ON s.id = m.station_id
|
||||||
|
JOIN (SELECT station_id, MAX(timestamp) AS latest_ts
|
||||||
|
FROM hii_waterlevel WHERE timestamp >= $1
|
||||||
|
GROUP BY station_id) latest
|
||||||
|
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
|
||||||
|
ORDER BY s.id";
|
||||||
|
|
||||||
|
async fn latest_feed(
|
||||||
|
st: Shared,
|
||||||
|
feed: &'static str,
|
||||||
|
sql: &'static str,
|
||||||
|
q: Option<String>,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
let a = params::parse(LATEST, ¶ms::query_map(q.as_deref()), &[], &h)?;
|
||||||
|
let hours = a.int("hours");
|
||||||
|
let pool = st.pool.clone();
|
||||||
|
let (body, stale) = st
|
||||||
|
.caches
|
||||||
|
.hii
|
||||||
|
.get(
|
||||||
|
format!("{feed}:{hours}"),
|
||||||
|
secs(st.cfg.web.hii_cache_ttl_s),
|
||||||
|
None,
|
||||||
|
move || {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
let cutoff = hours_before(bangkok_now(), hours)
|
||||||
|
.ok_or_else(|| ApiError::new(500, "date value out of range"))?;
|
||||||
|
let rows = rows_json(&sqlx::query(sql).bind(cutoff).fetch_all(&pool).await?);
|
||||||
|
let keep = !rows.is_empty();
|
||||||
|
Ok((to_body(&rows), keep))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ok(body, stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/hii/rainfall/latest: latest rainfall per HII station (Ping basin).
|
||||||
|
pub async fn rainfall_latest(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
RawQuery(q): RawQuery,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
st.metrics.count("hii_rainfall");
|
||||||
|
latest_feed(st, "rain", RAIN_SQL, q, h).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/hii/waterlevel/latest: latest water level per HII station (m MSL).
|
||||||
|
pub async fn waterlevel_latest(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
RawQuery(q): RawQuery,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
st.metrics.count("hii_waterlevel");
|
||||||
|
latest_feed(st, "waterlevel", WATERLEVEL_SQL, q, h).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The upper Ping above P.1 (src/ml/hii_rain.py CATCHMENT_BOX):
|
||||||
|
/// lat_lo, lat_hi, lon_lo, lon_hi.
|
||||||
|
const CATCHMENT_BOX: [f64; 4] = [18.75, 19.60, 98.60, 99.30];
|
||||||
|
const MIN_GAUGES_PER_HOUR: i64 = 5;
|
||||||
|
const WINDOW_H: usize = 24;
|
||||||
|
|
||||||
|
pub const CATCHMENT: &[Param] = &[Param::int("days", 14).ge(1).le(60)];
|
||||||
|
|
||||||
|
/// `hii_rain.load_gauge_mean`: hourly catchment-mean rain_1h; hours with
|
||||||
|
/// fewer than MIN_GAUGES_PER_HOUR gauges are NaN.
|
||||||
|
async fn gauge_mean(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
start: NaiveDateTime,
|
||||||
|
) -> Option<Vec<(NaiveDateTime, f64)>> {
|
||||||
|
let [lat_lo, lat_hi, lon_lo, lon_hi] = CATCHMENT_BOX.map(|x| x.to_string());
|
||||||
|
let rows: Vec<(NaiveDateTime, i64, Option<f64>)> = sqlx::query_as(
|
||||||
|
"SELECT m.timestamp, COUNT(m.rain_1h) AS n, AVG(m.rain_1h)::float8 AS rain_1h
|
||||||
|
FROM hii_rainfall m JOIN hii_rain_stations s ON s.id = m.station_id
|
||||||
|
WHERE s.latitude BETWEEN $1::numeric AND $2::numeric
|
||||||
|
AND s.longitude BETWEEN $3::numeric AND $4::numeric
|
||||||
|
AND m.rain_1h IS NOT NULL AND m.timestamp >= $5
|
||||||
|
GROUP BY m.timestamp ORDER BY m.timestamp",
|
||||||
|
)
|
||||||
|
.bind(lat_lo)
|
||||||
|
.bind(lat_hi)
|
||||||
|
.bind(lon_lo)
|
||||||
|
.bind(lon_hi)
|
||||||
|
.bind(start)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| tracing::warn!("HII gauge rain load failed: {e}"))
|
||||||
|
.ok()?;
|
||||||
|
if rows.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// groupby(floor('h')).agg(n=sum, rain_1h=mean): pandas' group_mean
|
||||||
|
// (Kahan-compensated sum / count)
|
||||||
|
struct Acc {
|
||||||
|
n: i64,
|
||||||
|
sum: f64,
|
||||||
|
comp: f64,
|
||||||
|
nobs: u32,
|
||||||
|
}
|
||||||
|
let mut groups: BTreeMap<NaiveDateTime, Acc> = BTreeMap::new();
|
||||||
|
for (t, n, rain) in rows {
|
||||||
|
let g = groups.entry(floor_hour(t)).or_insert(Acc {
|
||||||
|
n: 0,
|
||||||
|
sum: 0.0,
|
||||||
|
comp: 0.0,
|
||||||
|
nobs: 0,
|
||||||
|
});
|
||||||
|
g.n += n;
|
||||||
|
if let Some(val) = rain.filter(|v| !v.is_nan()) {
|
||||||
|
g.nobs += 1;
|
||||||
|
let y = val - g.comp;
|
||||||
|
let t = g.sum + y;
|
||||||
|
g.comp = t - g.sum - y;
|
||||||
|
if g.comp.is_nan() {
|
||||||
|
g.comp = 0.0;
|
||||||
|
}
|
||||||
|
g.sum = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|(t, g)| {
|
||||||
|
let mean = if g.nobs == 0 {
|
||||||
|
f64::NAN
|
||||||
|
} else {
|
||||||
|
g.sum / g.nobs as f64
|
||||||
|
};
|
||||||
|
(
|
||||||
|
t,
|
||||||
|
if g.n < MIN_GAUGES_PER_HOUR {
|
||||||
|
f64::NAN
|
||||||
|
} else {
|
||||||
|
mean
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hii_rain.compare_with_openmeteo`: agreement of 24 h sums over the hours
|
||||||
|
/// both series have.
|
||||||
|
fn compare(gauge: &[(NaiveDateTime, f64)], openmeteo: &[(NaiveDateTime, f64)]) -> Value {
|
||||||
|
let om: std::collections::HashMap<NaiveDateTime, f64> = openmeteo.iter().copied().collect();
|
||||||
|
let joined: Vec<(f64, f64)> = gauge
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, g)| !g.is_nan())
|
||||||
|
.filter_map(|(t, g)| om.get(t).filter(|o| !o.is_nan()).map(|o| (*g, *o)))
|
||||||
|
.collect();
|
||||||
|
if joined.is_empty() {
|
||||||
|
return json!({"overlap_hours": 0});
|
||||||
|
}
|
||||||
|
if joined.len() < WINDOW_H {
|
||||||
|
return json!({"overlap_hours": joined.len()});
|
||||||
|
}
|
||||||
|
let gs: Vec<f64> = joined.iter().map(|p| p.0).collect();
|
||||||
|
let os: Vec<f64> = joined.iter().map(|p| p.1).collect();
|
||||||
|
let g = ping_forecast::rolling::rolling_sum(&gs, WINDOW_H)[WINDOW_H - 1..].to_vec();
|
||||||
|
let o = ping_forecast::rolling::rolling_sum(&os, WINDOW_H)[WINDOW_H - 1..].to_vec();
|
||||||
|
let diff: Vec<f64> = o.iter().zip(&g).map(|(o, g)| o - g).collect();
|
||||||
|
let abs: Vec<f64> = diff.iter().map(|d| d.abs()).collect();
|
||||||
|
json!({
|
||||||
|
"overlap_hours": joined.len(),
|
||||||
|
"window_h": WINDOW_H,
|
||||||
|
"gauge_mean_mm": num(Some(numpy_mean(&g))),
|
||||||
|
"openmeteo_mean_mm": num(Some(numpy_mean(&o))),
|
||||||
|
"bias_mm": num(Some(numpy_mean(&diff))),
|
||||||
|
"mae_mm": num(Some(numpy_mean(&abs))),
|
||||||
|
"corr": num(Some(pearson(&g, &o))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `np.corrcoef(a, b)[0, 1]` (same formula; numpy's BLAS dot may differ in
|
||||||
|
/// the last bits). NaN for a constant series, which JSON shows as null.
|
||||||
|
fn pearson(a: &[f64], b: &[f64]) -> f64 {
|
||||||
|
let (ma, mb) = (numpy_mean(a), numpy_mean(b));
|
||||||
|
let da: Vec<f64> = a.iter().map(|x| x - ma).collect();
|
||||||
|
let db: Vec<f64> = b.iter().map(|x| x - mb).collect();
|
||||||
|
let dot = |x: &[f64], y: &[f64]| x.iter().zip(y).map(|(p, q)| p * q).sum::<f64>();
|
||||||
|
let fact = (a.len() - 1) as f64;
|
||||||
|
let (cab, caa, cbb) = (
|
||||||
|
dot(&da, &db) / fact,
|
||||||
|
dot(&da, &da) / fact,
|
||||||
|
dot(&db, &db) / fact,
|
||||||
|
);
|
||||||
|
(cab / caa.sqrt() / cbb.sqrt()).clamp(-1.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn series_rows(s: &[(NaiveDateTime, f64)]) -> Vec<Value> {
|
||||||
|
s.iter()
|
||||||
|
.map(|(t, v)| {
|
||||||
|
json!({
|
||||||
|
"timestamp": isoformat(*t),
|
||||||
|
"rain_mm": if v.is_nan() { Value::Null } else { json!(py_round(*v, 2)) },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/hii/rainfall/catchment: HII gauges vs the Open-Meteo series the
|
||||||
|
/// flood model uses, plus their agreement over the window.
|
||||||
|
pub async fn rainfall_catchment(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
RawQuery(q): RawQuery,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
st.metrics.count("hii_rain_catchment");
|
||||||
|
let a = params::parse(CATCHMENT, ¶ms::query_map(q.as_deref()), &[], &h)?;
|
||||||
|
let days = a.int("days");
|
||||||
|
let pool = st.pool.clone();
|
||||||
|
let (body, stale) = st
|
||||||
|
.caches
|
||||||
|
.hii
|
||||||
|
.get(
|
||||||
|
format!("rain_catchment:{days}"),
|
||||||
|
secs(st.cfg.web.hii_cache_ttl_s),
|
||||||
|
None,
|
||||||
|
move || {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
let start = bangkok_now() - TimeDelta::days(days);
|
||||||
|
let gauge = gauge_mean(&pool, start).await;
|
||||||
|
let openmeteo: Option<Vec<(NaiveDateTime, f64)>> =
|
||||||
|
match sqlx::query_as::<_, (NaiveDateTime, Option<f64>)>(
|
||||||
|
"SELECT timestamp, catchment_mean::float8 FROM openmeteo_rain
|
||||||
|
WHERE timestamp >= $1 ORDER BY timestamp",
|
||||||
|
)
|
||||||
|
.bind(start)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) if !rows.is_empty() => Some(
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|(t, v)| (t, v.unwrap_or(f64::NAN)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("openmeteo_rain read failed: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let comparison = match (&gauge, &openmeteo) {
|
||||||
|
(Some(g), Some(o)) => compare(g, o),
|
||||||
|
_ => json!({"overlap_hours": 0}),
|
||||||
|
};
|
||||||
|
let body = json!({
|
||||||
|
"box": CATCHMENT_BOX,
|
||||||
|
"gauge": gauge.as_deref().map(series_rows).unwrap_or_default(),
|
||||||
|
"openmeteo": openmeteo.as_deref().map(series_rows).unwrap_or_default(),
|
||||||
|
"comparison_24h_sums": comparison,
|
||||||
|
});
|
||||||
|
Ok((to_body(&body), true))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ok(body, stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const THAIWATER_URL: &str = "https://twa-api-public.thaiwater.net/v2/waterlevel";
|
||||||
|
|
||||||
|
/// `ThaiWaterClient._parse_ping_features`: the Ping-basin sensors.
|
||||||
|
pub fn parse_ping_features(payload: &Value) -> Vec<Value> {
|
||||||
|
let empty = Map::new();
|
||||||
|
let obj = |v: Option<&Value>| -> Map<String, Value> {
|
||||||
|
v.and_then(Value::as_object).cloned().unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mut out = vec![];
|
||||||
|
let collections = payload
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.unwrap_or(&empty);
|
||||||
|
for collection in collections.values() {
|
||||||
|
let features = collection.get("features").and_then(Value::as_array);
|
||||||
|
for feature in features.into_iter().flatten() {
|
||||||
|
let props = obj(feature.get("properties"));
|
||||||
|
let basin = obj(props.get("basin"));
|
||||||
|
if basin.get("basin").and_then(Value::as_str) != Some("Ping") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let geometry = obj(feature.get("geometry"));
|
||||||
|
let coords = geometry
|
||||||
|
.get("coordinates")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
if coords.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let station = obj(props.get("station"));
|
||||||
|
let code = station
|
||||||
|
.get("stationCode")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let get = |m: &Map<String, Value>, k: &str| m.get(k).cloned().unwrap_or(Value::Null);
|
||||||
|
out.push(json!({
|
||||||
|
"id": format!("thaiwater:{}", py_str(props.get("id").unwrap_or(&Value::Null))),
|
||||||
|
"station_code": code.split_once('-').map_or(code, |(_, rest)| rest),
|
||||||
|
"station_name": get(&station, "station"),
|
||||||
|
"latitude": coords[1],
|
||||||
|
"longitude": coords[0],
|
||||||
|
"timestamp": get(&props, "waterlevelDatetime"),
|
||||||
|
"water_level_msl": get(&props, "waterlevelMsl"),
|
||||||
|
"bank_percent": get(&props, "storagePercent"),
|
||||||
|
"distance_to_bank": get(&props, "diffWlBank"),
|
||||||
|
"river_name": get(&props, "riverName"),
|
||||||
|
"agency": get(&obj(props.get("agency")), "agencyShort"),
|
||||||
|
"source": "ThaiWater",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /sensors/thaiwater: current ThaiWater water-level sensors in the Ping
|
||||||
|
/// basin, fetched live (cached for a minute; the feed updates every ~10 min).
|
||||||
|
pub async fn thaiwater_sensors(State(st): State<Shared>) -> ApiResult {
|
||||||
|
st.metrics.count("thaiwater_sensors");
|
||||||
|
let Some(key) = st.cfg.web.thaiwater_api_key.clone() else {
|
||||||
|
return Err(ApiError::new(503, "THAIWATER_API_KEY is not configured"));
|
||||||
|
};
|
||||||
|
let (http, timeout) = (st.http.clone(), st.cfg.web.request_timeout_s);
|
||||||
|
let (body, stale) = st
|
||||||
|
.caches
|
||||||
|
.sensors
|
||||||
|
.get("ping".into(), Duration::from_secs(60), None, move || {
|
||||||
|
let (http, key) = (http.clone(), key.clone());
|
||||||
|
async move {
|
||||||
|
let fetch = async {
|
||||||
|
http.get(THAIWATER_URL)
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.header("x-api-key", key)
|
||||||
|
.timeout(Duration::from_secs(timeout))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
match fetch.await {
|
||||||
|
Ok(payload) => {
|
||||||
|
let sensors = parse_ping_features(&payload);
|
||||||
|
let keep = !sensors.is_empty();
|
||||||
|
Ok((to_body(&sensors), keep))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Error fetching ThaiWater sensors: {e}");
|
||||||
|
Err(ApiError::new(502, "ThaiWater API unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
ok(body, stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thaiwater_features() {
|
||||||
|
let payload = json!({"data": {"a": {"features": [
|
||||||
|
{"properties": {"id": 2186, "basin": {"basin": "Ping"},
|
||||||
|
"station": {"stationCode": "FOP-MOU298", "station": "Mae Tom Bridge"},
|
||||||
|
"waterlevelDatetime": "2026-10-18T15:10:00+07:00", "waterlevelMsl": 794.41,
|
||||||
|
"storagePercent": -10.76, "diffWlBank": 1.9, "riverName": "Nam Mae Tuen",
|
||||||
|
"agency": {"agencyShort": "FOP"}},
|
||||||
|
"geometry": {"coordinates": [98.36419, 17.799877]}},
|
||||||
|
{"properties": {"id": 1, "basin": {"basin": "Wang"}},
|
||||||
|
"geometry": {"coordinates": [99.0, 18.0]}},
|
||||||
|
{"properties": {"basin": {"basin": "Ping"}}, "geometry": {"coordinates": [99.0]}},
|
||||||
|
{"properties": {"basin": {"basin": "Ping"}}, "geometry": {"coordinates": [99.0, 18.0]}}
|
||||||
|
]}}});
|
||||||
|
let s = parse_ping_features(&payload);
|
||||||
|
assert_eq!(s.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
s[0].to_string(),
|
||||||
|
r#"{"id":"thaiwater:2186","station_code":"MOU298","station_name":"Mae Tom Bridge","latitude":17.799877,"longitude":98.36419,"timestamp":"2026-10-18T15:10:00+07:00","water_level_msl":794.41,"bank_percent":-10.76,"distance_to_bank":1.9,"river_name":"Nam Mae Tuen","agency":"FOP","source":"ThaiWater"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(s[1]["id"], "thaiwater:None");
|
||||||
|
assert_eq!(s[1]["station_code"], "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
//! The website and read API: the port of the FastAPI app (src/web_api.py).
|
||||||
|
//!
|
||||||
|
//! Same routes, parameters, JSON fields, status codes, cache headers and
|
||||||
|
//! response caches as the Python app, checked route by route against it by
|
||||||
|
//! scripts/api_parity.py. It runs inside `ping-collector run` (WEB_LISTEN)
|
||||||
|
//! or alone (`ping-collector serve`). /docs is the same Swagger UI, fed by an
|
||||||
|
//! OpenAPI document built from the route table that also validates the
|
||||||
|
//! parameters (openapi.rs, params.rs).
|
||||||
|
|
||||||
|
mod cache;
|
||||||
|
mod data;
|
||||||
|
mod error;
|
||||||
|
mod forecasts;
|
||||||
|
mod hii;
|
||||||
|
mod openapi;
|
||||||
|
mod params;
|
||||||
|
mod pyfmt;
|
||||||
|
mod system;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::stations::Stations;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use axum::Router;
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::handler::HandlerWithoutStateExt;
|
||||||
|
use axum::http::{HeaderValue, Method, StatusCode, header};
|
||||||
|
use axum::middleware::{self, Next};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use cache::Swr;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
|
use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove};
|
||||||
|
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, Any, CorsLayer};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub cfg: Config,
|
||||||
|
pub pool: PgPool,
|
||||||
|
pub http: reqwest::Client,
|
||||||
|
pub stations: Arc<RwLock<Stations>>,
|
||||||
|
/// The collector's status when it runs in this process; otherwise
|
||||||
|
/// /scraping/status reads SCRAPING_STATUS_FILE, as the Python app did.
|
||||||
|
pub status: Option<Arc<RwLock<Value>>>,
|
||||||
|
/// Wakes the collection loop (POST /scrape/trigger).
|
||||||
|
pub trigger: Option<Arc<tokio::sync::Notify>>,
|
||||||
|
pub dashboard: String,
|
||||||
|
pub metrics: Metrics,
|
||||||
|
pub started: Instant,
|
||||||
|
caches: Caches,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Shared = Arc<AppState>;
|
||||||
|
|
||||||
|
struct Caches {
|
||||||
|
latest: Arc<Swr<Bytes>>,
|
||||||
|
history: Arc<Swr<Bytes>>,
|
||||||
|
hii: Arc<Swr<Bytes>>,
|
||||||
|
stats: Arc<Swr<Bytes>>,
|
||||||
|
health: Arc<Swr<Bytes>>,
|
||||||
|
sensors: Arc<Swr<Bytes>>,
|
||||||
|
/// body, older than FORECAST_MAX_AGE_SECONDS
|
||||||
|
forecast: Arc<Swr<(Bytes, bool)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(
|
||||||
|
cfg: Config,
|
||||||
|
pool: PgPool,
|
||||||
|
http: reqwest::Client,
|
||||||
|
stations: Arc<RwLock<Stations>>,
|
||||||
|
status: Option<Arc<RwLock<Value>>>,
|
||||||
|
trigger: Option<Arc<tokio::sync::Notify>>,
|
||||||
|
) -> Shared {
|
||||||
|
let path = cfg.web.static_dir.join("dashboard.html");
|
||||||
|
let dashboard = std::fs::read_to_string(&path).unwrap_or_else(|e| {
|
||||||
|
tracing::error!("could not load the dashboard {}: {e}", path.display());
|
||||||
|
"<h1>Northern Thailand Ping River Monitor API</h1><p>See <code>/docs</code>.</p>".into()
|
||||||
|
});
|
||||||
|
Arc::new(AppState {
|
||||||
|
cfg,
|
||||||
|
pool,
|
||||||
|
http,
|
||||||
|
stations,
|
||||||
|
status,
|
||||||
|
trigger,
|
||||||
|
dashboard,
|
||||||
|
metrics: Metrics::default(),
|
||||||
|
started: Instant::now(),
|
||||||
|
// byte budgets: ~100 MB in all, inside the unit's MemoryMax
|
||||||
|
caches: Caches {
|
||||||
|
latest: Swr::new(4 * MB),
|
||||||
|
history: Swr::new(48 * MB),
|
||||||
|
hii: Swr::new(24 * MB),
|
||||||
|
stats: Swr::new(MB),
|
||||||
|
health: Swr::new(MB),
|
||||||
|
sensors: Swr::new(4 * MB),
|
||||||
|
forecast: Swr::new(4 * MB),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB: usize = 1 << 20;
|
||||||
|
|
||||||
|
fn secs(s: u64) -> Duration {
|
||||||
|
Duration::from_secs(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/metrics`: request counters per endpoint, as the Python app kept them.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Metrics {
|
||||||
|
counters: Mutex<Map<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metrics {
|
||||||
|
pub fn count(&self, endpoint: &str) {
|
||||||
|
let key = format!("api_requests{{endpoint={endpoint}}}");
|
||||||
|
let mut c = self.counters.lock().unwrap();
|
||||||
|
let n = c.get(&key).and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
c.insert(key, json!(n + 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn counters(&self) -> Value {
|
||||||
|
Value::Object(self.counters.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache-Control by path, as the Python app's middleware: lets browsers and
|
||||||
|
/// Cloudflare absorb repeat traffic. Max-ages mirror the data cadence.
|
||||||
|
const CACHE_CONTROL: &[(&str, &str)] = &[
|
||||||
|
("/static/", "public, max-age=3600"),
|
||||||
|
("/measurements/latest", "public, max-age=30"),
|
||||||
|
("/api/forecast/", "public, max-age=300"),
|
||||||
|
("/api/hii/", "public, max-age=60"),
|
||||||
|
("/measurements/history", "public, max-age=300"),
|
||||||
|
("/forecast", "public, max-age=120"),
|
||||||
|
("/api/stats", "public, max-age=300"),
|
||||||
|
("/stations", "public, max-age=300"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Requests reported to Umami (server-side API analytics).
|
||||||
|
const UMAMI_PREFIXES: &[&str] = &[
|
||||||
|
"/api/",
|
||||||
|
"/measurements",
|
||||||
|
"/forecast",
|
||||||
|
"/stations",
|
||||||
|
"/sensors",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn cache_control(path: &str) -> Option<&'static str> {
|
||||||
|
if path == "/" {
|
||||||
|
return Some("public, max-age=120");
|
||||||
|
}
|
||||||
|
CACHE_CONTROL
|
||||||
|
.iter()
|
||||||
|
.find(|(prefix, _)| path.starts_with(prefix))
|
||||||
|
.map(|(_, v)| *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish(State(st): State<Shared>, req: Request, next: Next) -> Response {
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let method = req.method().clone();
|
||||||
|
let (host, user_agent) = {
|
||||||
|
let header = |name: header::HeaderName| {
|
||||||
|
req.headers()
|
||||||
|
.get(name)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(String::from)
|
||||||
|
};
|
||||||
|
(
|
||||||
|
header(header::HOST).unwrap_or_else(|| "water.buildfor.life".into()),
|
||||||
|
header(header::USER_AGENT).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut resp = next.run(req).await;
|
||||||
|
if method == Method::GET
|
||||||
|
&& resp.status() == StatusCode::OK
|
||||||
|
&& !resp.headers().contains_key(header::CACHE_CONTROL)
|
||||||
|
&& let Some(v) = cache_control(&path)
|
||||||
|
{
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static(v));
|
||||||
|
}
|
||||||
|
let web = &st.cfg.web;
|
||||||
|
if web.umami_track_api
|
||||||
|
&& !web.umami_website_id.is_empty()
|
||||||
|
&& UMAMI_PREFIXES.iter().any(|p| path.starts_with(p))
|
||||||
|
{
|
||||||
|
let event = json!({
|
||||||
|
"type": "event",
|
||||||
|
"payload": {
|
||||||
|
"website": web.umami_website_id,
|
||||||
|
"url": path,
|
||||||
|
"hostname": host,
|
||||||
|
"name": "api-request",
|
||||||
|
"data": {"method": method.as_str(), "status": resp.status().as_u16()},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let req = st
|
||||||
|
.http
|
||||||
|
.post(&web.umami_api_url)
|
||||||
|
.timeout(Duration::from_secs(3))
|
||||||
|
.header(
|
||||||
|
header::USER_AGENT,
|
||||||
|
if user_agent.is_empty() {
|
||||||
|
"api-client".into()
|
||||||
|
} else {
|
||||||
|
user_agent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.json(&event);
|
||||||
|
// analytics must never affect the response
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = req.send().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn not_found() -> Response {
|
||||||
|
error::ApiError::new(404, "Not Found").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn method_not_allowed() -> Response {
|
||||||
|
error::ApiError::new(405, "Method Not Allowed").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cors(cfg: &Config) -> CorsLayer {
|
||||||
|
// as the Python app: any origin without credentials unless
|
||||||
|
// CORS_ALLOW_ORIGINS lists the allowed ones
|
||||||
|
let origins = &cfg.web.cors_allow_origins;
|
||||||
|
if origins.is_empty() {
|
||||||
|
return CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
}
|
||||||
|
let list: Vec<HeaderValue> = origins.iter().filter_map(|o| o.parse().ok()).collect();
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::list(list))
|
||||||
|
.allow_credentials(true)
|
||||||
|
.allow_methods(AllowMethods::mirror_request())
|
||||||
|
.allow_headers(AllowHeaders::mirror_request())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(st: Shared) -> Router {
|
||||||
|
let static_files =
|
||||||
|
ServeDir::new(st.cfg.web.static_dir.clone()).not_found_service(not_found.into_service());
|
||||||
|
// as Starlette's GZipMiddleware(minimum_size=500)
|
||||||
|
let compress = CompressionLayer::new().compress_when(
|
||||||
|
SizeAbove::new(500)
|
||||||
|
.and(NotForContentType::GRPC)
|
||||||
|
.and(NotForContentType::IMAGES)
|
||||||
|
.and(NotForContentType::SSE),
|
||||||
|
);
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(system::root))
|
||||||
|
.route("/robots.txt", get(system::robots))
|
||||||
|
.route("/llms.txt", get(system::llms))
|
||||||
|
.route("/sitemap.xml", get(system::sitemap))
|
||||||
|
.route("/docs", get(openapi::swagger_ui))
|
||||||
|
.route("/redoc", get(openapi::redoc))
|
||||||
|
.route("/openapi.json", get(openapi::spec))
|
||||||
|
.route("/health", get(system::health))
|
||||||
|
.route("/metrics", get(system::metrics))
|
||||||
|
.route(
|
||||||
|
"/stations",
|
||||||
|
get(system::stations).post(system::create_station),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/stations/{station_id}",
|
||||||
|
get(system::station)
|
||||||
|
.put(system::update_station)
|
||||||
|
.delete(system::delete_station),
|
||||||
|
)
|
||||||
|
.route("/sensors/thaiwater", get(hii::thaiwater_sensors))
|
||||||
|
.route("/api/hii/rainfall/latest", get(hii::rainfall_latest))
|
||||||
|
.route("/api/hii/rainfall/catchment", get(hii::rainfall_catchment))
|
||||||
|
.route("/api/hii/waterlevel/latest", get(hii::waterlevel_latest))
|
||||||
|
.route("/measurements/history/{station_code}", get(data::history))
|
||||||
|
.route("/forecast", get(forecasts::forecast))
|
||||||
|
.route(
|
||||||
|
"/api/forecast/history/{station_code}",
|
||||||
|
get(forecasts::history),
|
||||||
|
)
|
||||||
|
.route("/api/notifications", get(forecasts::notifications))
|
||||||
|
.route("/api/forecast/skill", get(forecasts::skill))
|
||||||
|
.route("/measurements/latest", get(data::latest))
|
||||||
|
.route("/measurements/station/{station_code}", get(data::station))
|
||||||
|
.route("/api/stats", get(data::stats))
|
||||||
|
.route("/scrape/trigger", post(system::trigger))
|
||||||
|
.route("/scraping/status", get(system::scraping_status))
|
||||||
|
.route("/config", get(system::config))
|
||||||
|
.nest_service("/static", static_files)
|
||||||
|
.fallback(not_found)
|
||||||
|
.method_not_allowed_fallback(method_not_allowed)
|
||||||
|
.layer(middleware::from_fn_with_state(st.clone(), finish))
|
||||||
|
.layer(cors(&st.cfg))
|
||||||
|
.layer(compress)
|
||||||
|
.with_state(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve until `shutdown` resolves. While the address is taken (the Python
|
||||||
|
/// app still running during a switch-over) binding is retried every 30 s.
|
||||||
|
pub async fn serve(
|
||||||
|
st: Shared,
|
||||||
|
listen: String,
|
||||||
|
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut shutdown = Box::pin(shutdown);
|
||||||
|
let listener = loop {
|
||||||
|
match tokio::net::TcpListener::bind(&listen).await {
|
||||||
|
Ok(l) => break l,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("website: cannot listen on {listen}: {e}; retrying in 30 s");
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(30)) => {}
|
||||||
|
_ = &mut shutdown => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::info!("website and API on http://{listen}");
|
||||||
|
axum::serve(listener, router(st))
|
||||||
|
.with_graceful_shutdown(shutdown)
|
||||||
|
.await
|
||||||
|
.context("web server")
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
//! /docs, /redoc and /openapi.json. The document is built from the route
|
||||||
|
//! table below, whose parameter lists are the same constants the handlers
|
||||||
|
//! validate with, so the documented parameters are the accepted ones.
|
||||||
|
//! Summaries, operation ids and descriptions follow FastAPI's conventions
|
||||||
|
//! (from the Python handler names and docstrings) so /docs reads as before.
|
||||||
|
|
||||||
|
use super::params::{Param, python_title};
|
||||||
|
use super::{data, forecasts, hii, system};
|
||||||
|
use axum::http::header;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub const TITLE: &str = "Northern Thailand Ping River Monitor API";
|
||||||
|
const DESCRIPTION: &str =
|
||||||
|
"Real-time water level monitoring system for Northern Thailand's Ping River Basin stations";
|
||||||
|
const VERSION: &str = "3.1.3";
|
||||||
|
|
||||||
|
enum Resp {
|
||||||
|
Any,
|
||||||
|
Html,
|
||||||
|
Ref(&'static str),
|
||||||
|
ArrayOf(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Route {
|
||||||
|
method: &'static str,
|
||||||
|
path: &'static str,
|
||||||
|
/// the Python handler's name (summary and operationId)
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
params: &'static [Param],
|
||||||
|
body: Option<&'static str>,
|
||||||
|
response: Resp,
|
||||||
|
}
|
||||||
|
|
||||||
|
const NONE: &[Param] = &[];
|
||||||
|
|
||||||
|
const ROUTES: &[Route] = &[
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/",
|
||||||
|
name: "root",
|
||||||
|
description: "Root endpoint with basic dashboard",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Html,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/health",
|
||||||
|
name: "get_health",
|
||||||
|
description: "Get system health status",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Ref("HealthResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/metrics",
|
||||||
|
name: "get_metrics",
|
||||||
|
description: "Get application metrics",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Ref("MetricsResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/stations",
|
||||||
|
name: "get_stations",
|
||||||
|
description: "Get list of all monitoring stations",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::ArrayOf("StationResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "post",
|
||||||
|
path: "/stations",
|
||||||
|
name: "create_station",
|
||||||
|
description: "Create a new monitoring station",
|
||||||
|
params: system::ADMIN,
|
||||||
|
body: Some("StationCreateModel"),
|
||||||
|
response: Resp::Ref("StationResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "put",
|
||||||
|
path: "/stations/{station_id}",
|
||||||
|
name: "update_station",
|
||||||
|
description: "Update an existing monitoring station",
|
||||||
|
params: system::ADMIN_STATION_ID,
|
||||||
|
body: Some("StationUpdateModel"),
|
||||||
|
response: Resp::Ref("StationResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "delete",
|
||||||
|
path: "/stations/{station_id}",
|
||||||
|
name: "delete_station",
|
||||||
|
description: "Delete a monitoring station",
|
||||||
|
params: system::ADMIN_STATION_ID,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/stations/{station_id}",
|
||||||
|
name: "get_station",
|
||||||
|
description: "Get details of a specific monitoring station",
|
||||||
|
params: system::STATION_ID,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Ref("StationResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/sensors/thaiwater",
|
||||||
|
name: "get_thaiwater_sensors",
|
||||||
|
description: "Get current ThaiWater water-level sensors in the Ping basin.",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/api/hii/rainfall/latest",
|
||||||
|
name: "get_hii_rainfall_latest",
|
||||||
|
description: "Latest rainfall reading per HII station (Ping basin, collected hourly).",
|
||||||
|
params: hii::LATEST,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/api/hii/rainfall/catchment",
|
||||||
|
name: "get_hii_rainfall_catchment",
|
||||||
|
description: "Upper-Ping catchment-mean hourly rain: HII gauges vs the Open-Meteo\nseries the flood model actually uses, plus their agreement over the window.\n\nEvidence-gathering endpoint (docs/FLOOD_FORECASTING.md, HII gauge rain):\nthe gauge table only exists since 2026-08 so it cannot be a training\nfeature yet; this makes the two sources' relationship observable meanwhile.",
|
||||||
|
params: hii::CATCHMENT,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/api/hii/waterlevel/latest",
|
||||||
|
name: "get_hii_waterlevel_latest",
|
||||||
|
description: "Latest water-level reading per HII station (Ping basin, m MSL).",
|
||||||
|
params: hii::LATEST,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/measurements/history/{station_code}",
|
||||||
|
name: "get_postgres_history",
|
||||||
|
description: "Get historical measurements for a station from the configured database.",
|
||||||
|
params: data::HISTORY,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/forecast",
|
||||||
|
name: "get_flood_forecasts",
|
||||||
|
description: "Flood-risk forecasts per station for the 6/12/24 h horizons.",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/api/forecast/history/{station_code}",
|
||||||
|
name: "get_forecast_history",
|
||||||
|
description: "Issued model forecasts for a station — what the model predicted, when.\n\nPopulated by the hourly precompute; enables predicted-vs-actual charts.",
|
||||||
|
params: forecasts::HISTORY,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/api/notifications",
|
||||||
|
name: "get_notifications_config",
|
||||||
|
description: "Public ntfy settings so the dashboard can offer subscribe links.",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/api/forecast/skill",
|
||||||
|
name: "get_forecast_skill",
|
||||||
|
description: "Is the model getting better? Issued forecasts verified against what the\nriver then did, per model version, with a persistence baseline.\n\nRead from forecast_history (what each deployed version predicted, hourly)\njoined to water_measurements; no retraining involved. Cached like the HII\nfeeds because the join is a few hundred correlated subqueries.",
|
||||||
|
params: forecasts::SKILL,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/measurements/latest",
|
||||||
|
name: "get_latest_measurements",
|
||||||
|
description: "Get latest measurements from all stations",
|
||||||
|
params: data::LATEST,
|
||||||
|
body: None,
|
||||||
|
response: Resp::ArrayOf("MeasurementResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/measurements/station/{station_code}",
|
||||||
|
name: "get_station_measurements",
|
||||||
|
description: "Get measurements for a specific station",
|
||||||
|
params: data::STATION,
|
||||||
|
body: None,
|
||||||
|
response: Resp::ArrayOf("MeasurementResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/api/stats",
|
||||||
|
name: "get_database_stats",
|
||||||
|
description: "Get database coverage statistics (totals, date range, hourly coverage)",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "post",
|
||||||
|
path: "/scrape/trigger",
|
||||||
|
name: "trigger_scraping",
|
||||||
|
description: "Trigger manual data scraping",
|
||||||
|
params: system::ADMIN,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/scraping/status",
|
||||||
|
name: "get_scraping_status",
|
||||||
|
description: "Get current scraping status",
|
||||||
|
params: NONE,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Ref("ScrapingStatusResponse"),
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
method: "get",
|
||||||
|
path: "/config",
|
||||||
|
name: "get_config",
|
||||||
|
description: "Get current configuration (sensitive data masked)",
|
||||||
|
params: system::ADMIN,
|
||||||
|
body: None,
|
||||||
|
response: Resp::Any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// FastAPI's `generate_unique_id`: name + path with non-word characters as
|
||||||
|
/// `_`, then the method.
|
||||||
|
fn operation_id(r: &Route) -> String {
|
||||||
|
let p: String = format!("{}{}", r.name, r.path)
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c == '_' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("{p}_{}", r.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(name: &str) -> Value {
|
||||||
|
json!({ "$ref": format!("#/components/schemas/{name}") })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation(r: &Route) -> Value {
|
||||||
|
let op_id = operation_id(r);
|
||||||
|
let ok_content = match r.response {
|
||||||
|
Resp::Html => json!({"text/html": {"schema": {"type": "string"}}}),
|
||||||
|
Resp::Any => json!({"application/json": {"schema": {}}}),
|
||||||
|
Resp::Ref(name) => json!({"application/json": {"schema": reference(name)}}),
|
||||||
|
Resp::ArrayOf(name) => json!({"application/json": {"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": reference(name),
|
||||||
|
"title": format!("Response {}", python_title(&op_id.replace('_', " "))),
|
||||||
|
}}}),
|
||||||
|
};
|
||||||
|
let mut responses = Map::new();
|
||||||
|
responses.insert(
|
||||||
|
"200".into(),
|
||||||
|
json!({"description": "Successful Response", "content": ok_content}),
|
||||||
|
);
|
||||||
|
if !r.params.is_empty() || r.body.is_some() {
|
||||||
|
responses.insert(
|
||||||
|
"422".into(),
|
||||||
|
json!({"description": "Validation Error",
|
||||||
|
"content": {"application/json": {"schema": reference("HTTPValidationError")}}}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut op = Map::new();
|
||||||
|
op.insert(
|
||||||
|
"summary".into(),
|
||||||
|
json!(python_title(&r.name.replace('_', " "))),
|
||||||
|
);
|
||||||
|
op.insert("description".into(), json!(r.description));
|
||||||
|
op.insert("operationId".into(), json!(op_id));
|
||||||
|
if !r.params.is_empty() {
|
||||||
|
op.insert(
|
||||||
|
"parameters".into(),
|
||||||
|
Value::Array(r.params.iter().map(Param::openapi).collect()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(body) = r.body {
|
||||||
|
op.insert(
|
||||||
|
"requestBody".into(),
|
||||||
|
json!({"required": true, "content": {"application/json": {"schema": reference(body)}}}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
op.insert("responses".into(), Value::Object(responses));
|
||||||
|
Value::Object(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn document() -> &'static str {
|
||||||
|
static DOC: OnceLock<String> = OnceLock::new();
|
||||||
|
DOC.get_or_init(|| {
|
||||||
|
let mut paths = Map::new();
|
||||||
|
for r in ROUTES {
|
||||||
|
let entry = paths
|
||||||
|
.entry(r.path)
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
entry
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("object")
|
||||||
|
.insert(r.method.into(), operation(r));
|
||||||
|
}
|
||||||
|
let schemas: Value = serde_json::from_str(include_str!("openapi_components.json"))
|
||||||
|
.expect("openapi_components.json is valid JSON");
|
||||||
|
json!({
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {"title": TITLE, "description": DESCRIPTION, "version": VERSION},
|
||||||
|
"paths": paths,
|
||||||
|
"components": {"schemas": schemas},
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spec() -> Response {
|
||||||
|
([(header::CONTENT_TYPE, "application/json")], document()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html(body: String) -> Response {
|
||||||
|
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], body).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Swagger UI page FastAPI serves (assets from the jsDelivr CDN).
|
||||||
|
pub async fn swagger_ui() -> Response {
|
||||||
|
html(format!(
|
||||||
|
r##"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<title>{TITLE} - Swagger UI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui">
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
const ui = SwaggerUIBundle({{
|
||||||
|
url: '/openapi.json',
|
||||||
|
"dom_id": "#swagger-ui",
|
||||||
|
"layout": "BaseLayout",
|
||||||
|
"deepLinking": true,
|
||||||
|
"showExtensions": true,
|
||||||
|
"showCommonExtensions": true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
}})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"##
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn redoc() -> Response {
|
||||||
|
html(format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{TITLE} - ReDoc</title>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>body {{ margin: 0; padding: 0; }}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>ReDoc requires Javascript to function. Please enable it to browse the documentation.</noscript>
|
||||||
|
<redoc spec-url="/openapi.json"></redoc>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fastapi_naming() {
|
||||||
|
let doc: Value = serde_json::from_str(document()).unwrap();
|
||||||
|
let op = &doc["paths"]["/measurements/history/{station_code}"]["get"];
|
||||||
|
assert_eq!(
|
||||||
|
op["operationId"],
|
||||||
|
"get_postgres_history_measurements_history__station_code__get"
|
||||||
|
);
|
||||||
|
assert_eq!(op["summary"], "Get Postgres History");
|
||||||
|
assert_eq!(op["parameters"].as_array().unwrap().len(), 5);
|
||||||
|
let latest = &doc["paths"]["/measurements/latest"]["get"]["responses"]["200"];
|
||||||
|
assert_eq!(
|
||||||
|
latest["content"]["application/json"]["schema"]["title"],
|
||||||
|
"Response Get Latest Measurements Measurements Latest Get"
|
||||||
|
);
|
||||||
|
assert!(doc["paths"]["/stations"]["post"]["responses"]["422"].is_object());
|
||||||
|
assert!(
|
||||||
|
doc["paths"]["/health"]["get"]["responses"]
|
||||||
|
.get("422")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(doc["components"]["schemas"]["HTTPValidationError"].is_object());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
{
|
||||||
|
"HTTPValidationError": {
|
||||||
|
"properties": {
|
||||||
|
"detail": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ValidationError"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Detail"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "HTTPValidationError"
|
||||||
|
},
|
||||||
|
"HealthResponse": {
|
||||||
|
"properties": {
|
||||||
|
"overall_status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Overall Status"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Timestamp"
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "Checks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"overall_status",
|
||||||
|
"timestamp",
|
||||||
|
"checks"
|
||||||
|
],
|
||||||
|
"title": "HealthResponse"
|
||||||
|
},
|
||||||
|
"MeasurementResponse": {
|
||||||
|
"properties": {
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Timestamp"
|
||||||
|
},
|
||||||
|
"station_code": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Station Code"
|
||||||
|
},
|
||||||
|
"station_name_en": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Station Name En"
|
||||||
|
},
|
||||||
|
"station_name_th": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Station Name Th"
|
||||||
|
},
|
||||||
|
"water_level": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "Water Level"
|
||||||
|
},
|
||||||
|
"discharge": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Discharge"
|
||||||
|
},
|
||||||
|
"discharge_percent": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Discharge Percent"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Status",
|
||||||
|
"default": "active"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"timestamp",
|
||||||
|
"station_code",
|
||||||
|
"station_name_en",
|
||||||
|
"station_name_th",
|
||||||
|
"water_level"
|
||||||
|
],
|
||||||
|
"title": "MeasurementResponse"
|
||||||
|
},
|
||||||
|
"MetricsResponse": {
|
||||||
|
"properties": {
|
||||||
|
"counters": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "Counters"
|
||||||
|
},
|
||||||
|
"gauges": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "Gauges"
|
||||||
|
},
|
||||||
|
"histograms": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "Histograms"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"counters",
|
||||||
|
"gauges",
|
||||||
|
"histograms"
|
||||||
|
],
|
||||||
|
"title": "MetricsResponse"
|
||||||
|
},
|
||||||
|
"ScrapingStatusResponse": {
|
||||||
|
"properties": {
|
||||||
|
"is_running": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Is Running"
|
||||||
|
},
|
||||||
|
"last_run": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Last Run"
|
||||||
|
},
|
||||||
|
"next_run": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Next Run"
|
||||||
|
},
|
||||||
|
"total_runs": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Total Runs",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"successful_runs": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Successful Runs",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"failed_runs": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Failed Runs",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"feed_current": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Feed Current"
|
||||||
|
},
|
||||||
|
"latest_reading": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Latest Reading"
|
||||||
|
},
|
||||||
|
"readings_saved": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Readings Saved",
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"is_running"
|
||||||
|
],
|
||||||
|
"title": "ScrapingStatusResponse"
|
||||||
|
},
|
||||||
|
"StationCreateModel": {
|
||||||
|
"properties": {
|
||||||
|
"station_code": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Station Code",
|
||||||
|
"description": "Station code (e.g., P.1, P.20)"
|
||||||
|
},
|
||||||
|
"thai_name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Thai Name",
|
||||||
|
"description": "Thai name of the station"
|
||||||
|
},
|
||||||
|
"english_name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "English Name",
|
||||||
|
"description": "English name of the station"
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"maximum": 90.0,
|
||||||
|
"minimum": -90.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Latitude",
|
||||||
|
"description": "Latitude coordinate"
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"maximum": 180.0,
|
||||||
|
"minimum": -180.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Longitude",
|
||||||
|
"description": "Longitude coordinate"
|
||||||
|
},
|
||||||
|
"geohash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Geohash",
|
||||||
|
"description": "Geohash for the location"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Status",
|
||||||
|
"description": "Station status",
|
||||||
|
"default": "active"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"station_code",
|
||||||
|
"thai_name",
|
||||||
|
"english_name"
|
||||||
|
],
|
||||||
|
"title": "StationCreateModel"
|
||||||
|
},
|
||||||
|
"StationResponse": {
|
||||||
|
"properties": {
|
||||||
|
"station_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Station Id"
|
||||||
|
},
|
||||||
|
"station_code": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Station Code"
|
||||||
|
},
|
||||||
|
"thai_name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Thai Name"
|
||||||
|
},
|
||||||
|
"english_name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "English Name"
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Latitude"
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Longitude"
|
||||||
|
},
|
||||||
|
"geohash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Geohash"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Status",
|
||||||
|
"default": "active"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"station_id",
|
||||||
|
"station_code",
|
||||||
|
"thai_name",
|
||||||
|
"english_name"
|
||||||
|
],
|
||||||
|
"title": "StationResponse"
|
||||||
|
},
|
||||||
|
"StationUpdateModel": {
|
||||||
|
"properties": {
|
||||||
|
"thai_name": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Thai Name",
|
||||||
|
"description": "Thai name of the station"
|
||||||
|
},
|
||||||
|
"english_name": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "English Name",
|
||||||
|
"description": "English name of the station"
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"maximum": 90.0,
|
||||||
|
"minimum": -90.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Latitude",
|
||||||
|
"description": "Latitude coordinate"
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"maximum": 180.0,
|
||||||
|
"minimum": -180.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Longitude",
|
||||||
|
"description": "Longitude coordinate"
|
||||||
|
},
|
||||||
|
"geohash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Geohash",
|
||||||
|
"description": "Geohash for the location"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Status",
|
||||||
|
"description": "Station status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "StationUpdateModel"
|
||||||
|
},
|
||||||
|
"ValidationError": {
|
||||||
|
"properties": {
|
||||||
|
"loc": {
|
||||||
|
"items": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Location"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Message"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Error Type"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"title": "Input"
|
||||||
|
},
|
||||||
|
"ctx": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Context"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"loc",
|
||||||
|
"msg",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "ValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
//! Query/path parameters: one declaration per route drives both the
|
||||||
|
//! validation (pydantic-style 422 errors, as FastAPI) and the OpenAPI
|
||||||
|
//! document behind /docs, so the two cannot drift apart.
|
||||||
|
|
||||||
|
use super::error::ApiError;
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum In {
|
||||||
|
Query,
|
||||||
|
Path,
|
||||||
|
Header,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Kind {
|
||||||
|
Int,
|
||||||
|
Str,
|
||||||
|
Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum Default {
|
||||||
|
None,
|
||||||
|
Int(i64),
|
||||||
|
Str(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Param {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub loc: In,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub required: bool,
|
||||||
|
/// `Optional[...] = None`
|
||||||
|
pub nullable: bool,
|
||||||
|
pub default: Default,
|
||||||
|
pub ge: Option<i64>,
|
||||||
|
pub le: Option<i64>,
|
||||||
|
pub description: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Param {
|
||||||
|
const fn new(name: &'static str, loc: In, kind: Kind) -> Self {
|
||||||
|
Param {
|
||||||
|
name,
|
||||||
|
loc,
|
||||||
|
kind,
|
||||||
|
required: matches!(loc, In::Path),
|
||||||
|
nullable: false,
|
||||||
|
default: Default::None,
|
||||||
|
ge: None,
|
||||||
|
le: None,
|
||||||
|
description: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn path_str(name: &'static str) -> Self {
|
||||||
|
Self::new(name, In::Path, Kind::Str)
|
||||||
|
}
|
||||||
|
pub const fn path_int(name: &'static str) -> Self {
|
||||||
|
Self::new(name, In::Path, Kind::Int)
|
||||||
|
}
|
||||||
|
pub const fn int(name: &'static str, default: i64) -> Self {
|
||||||
|
Param {
|
||||||
|
default: Default::Int(default),
|
||||||
|
..Self::new(name, In::Query, Kind::Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn opt_int(name: &'static str) -> Self {
|
||||||
|
Param {
|
||||||
|
nullable: true,
|
||||||
|
..Self::new(name, In::Query, Kind::Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn str(name: &'static str, default: &'static str) -> Self {
|
||||||
|
Param {
|
||||||
|
default: Default::Str(default),
|
||||||
|
..Self::new(name, In::Query, Kind::Str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn opt_date(name: &'static str, description: &'static str) -> Self {
|
||||||
|
Param {
|
||||||
|
nullable: true,
|
||||||
|
description: Some(description),
|
||||||
|
..Self::new(name, In::Query, Kind::Date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// The optional `X-API-Key` header of the admin endpoints.
|
||||||
|
pub const fn api_key() -> Self {
|
||||||
|
Param {
|
||||||
|
nullable: true,
|
||||||
|
..Self::new("X-API-Key", In::Header, Kind::Str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn ge(self, v: i64) -> Self {
|
||||||
|
Param {
|
||||||
|
ge: Some(v),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn le(self, v: i64) -> Self {
|
||||||
|
Param {
|
||||||
|
le: Some(v),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loc_name(&self) -> &'static str {
|
||||||
|
match self.loc {
|
||||||
|
In::Query => "query",
|
||||||
|
In::Path => "path",
|
||||||
|
In::Header => "header",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pydantic's default title: `snake_case` / `X-API-Key` -> `Snake Case` / `X-Api-Key`
|
||||||
|
pub fn title(&self) -> String {
|
||||||
|
python_title(&self.name.replace('_', " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OpenAPI parameter object, as FastAPI writes it.
|
||||||
|
pub fn openapi(&self) -> Value {
|
||||||
|
let mut base = Map::new();
|
||||||
|
base.insert(
|
||||||
|
"type".into(),
|
||||||
|
json!(match self.kind {
|
||||||
|
Kind::Int => "integer",
|
||||||
|
Kind::Str | Kind::Date => "string",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if self.kind == Kind::Date {
|
||||||
|
base.insert("format".into(), json!("date"));
|
||||||
|
}
|
||||||
|
if let Some(le) = self.le {
|
||||||
|
base.insert("maximum".into(), json!(le));
|
||||||
|
}
|
||||||
|
if let Some(ge) = self.ge {
|
||||||
|
base.insert("minimum".into(), json!(ge));
|
||||||
|
}
|
||||||
|
let mut schema = if self.nullable {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert(
|
||||||
|
"anyOf".into(),
|
||||||
|
json!([Value::Object(base), {"type": "null"}]),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
};
|
||||||
|
match self.default {
|
||||||
|
Default::Int(d) => {
|
||||||
|
schema.insert("default".into(), json!(d));
|
||||||
|
}
|
||||||
|
Default::Str(d) => {
|
||||||
|
schema.insert("default".into(), json!(d));
|
||||||
|
}
|
||||||
|
Default::None => {}
|
||||||
|
}
|
||||||
|
if let Some(d) = self.description {
|
||||||
|
schema.insert("description".into(), json!(d));
|
||||||
|
}
|
||||||
|
schema.insert("title".into(), json!(self.title()));
|
||||||
|
let mut p = Map::new();
|
||||||
|
p.insert("name".into(), json!(self.name));
|
||||||
|
p.insert("in".into(), json!(self.loc_name()));
|
||||||
|
p.insert("required".into(), json!(self.required));
|
||||||
|
p.insert("schema".into(), Value::Object(schema));
|
||||||
|
if let Some(d) = self.description {
|
||||||
|
p.insert("description".into(), json!(d));
|
||||||
|
}
|
||||||
|
Value::Object(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Python's `str.title()` for ASCII.
|
||||||
|
pub fn python_title(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut prev_letter = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
out.extend(if prev_letter {
|
||||||
|
c.to_lowercase().collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
c.to_uppercase().collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
prev_letter = c.is_alphabetic();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Val {
|
||||||
|
Int(i64),
|
||||||
|
Str(String),
|
||||||
|
Date(NaiveDate),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Args(HashMap<&'static str, Val>);
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
pub fn int(&self, name: &str) -> i64 {
|
||||||
|
match self.0.get(name) {
|
||||||
|
Some(Val::Int(v)) => *v,
|
||||||
|
other => panic!("parameter {name}: expected an int, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn opt_int(&self, name: &str) -> Option<i64> {
|
||||||
|
match self.0.get(name) {
|
||||||
|
Some(Val::Int(v)) => Some(*v),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn str(&self, name: &str) -> &str {
|
||||||
|
match self.0.get(name) {
|
||||||
|
Some(Val::Str(v)) => v,
|
||||||
|
other => panic!("parameter {name}: expected a string, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn date(&self, name: &str) -> Option<NaiveDate> {
|
||||||
|
match self.0.get(name) {
|
||||||
|
Some(Val::Date(d)) => Some(*d),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Python's `str()` of the value (cache keys built like the Python app's).
|
||||||
|
pub fn py_str(&self, name: &str) -> String {
|
||||||
|
match self.0.get(name) {
|
||||||
|
Some(Val::Int(v)) => v.to_string(),
|
||||||
|
Some(Val::Str(s)) => s.clone(),
|
||||||
|
Some(Val::Date(d)) => d.format("%Y-%m-%d").to_string(),
|
||||||
|
Some(Val::None) | None => "None".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The query string as a map; a repeated name keeps its last value (as
|
||||||
|
/// Starlette's `QueryParams.get`).
|
||||||
|
pub fn query_map(raw: Option<&str>) -> HashMap<String, String> {
|
||||||
|
form_urlencoded::parse(raw.unwrap_or("").as_bytes())
|
||||||
|
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error(kind: &str, p: &Param, msg: String, input: Value, ctx: Option<Value>) -> Value {
|
||||||
|
let mut e = Map::new();
|
||||||
|
e.insert("type".into(), json!(kind));
|
||||||
|
e.insert("loc".into(), json!([p.loc_name(), p.name]));
|
||||||
|
e.insert("msg".into(), json!(msg));
|
||||||
|
e.insert("input".into(), input);
|
||||||
|
if let Some(c) = ctx {
|
||||||
|
e.insert("ctx".into(), c);
|
||||||
|
}
|
||||||
|
Value::Object(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_date(s: &str) -> Result<NaiveDate, &'static str> {
|
||||||
|
if s.len() < 10 {
|
||||||
|
return Err("input is too short");
|
||||||
|
}
|
||||||
|
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
|
||||||
|
return Ok(d);
|
||||||
|
}
|
||||||
|
// pydantic accepts a datetime at midnight for a date
|
||||||
|
for fmt in [
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.f",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
] {
|
||||||
|
if let Ok(t) = NaiveDateTime::parse_from_str(s, fmt) {
|
||||||
|
if t.time() == chrono::NaiveTime::MIN {
|
||||||
|
return Ok(t.date());
|
||||||
|
}
|
||||||
|
return Err("Datetimes provided to dates should have zero time - e.g. be exact dates");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("invalid date format, expected YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate every declared parameter; all problems are reported at once.
|
||||||
|
pub fn parse(
|
||||||
|
spec: &[Param],
|
||||||
|
query: &HashMap<String, String>,
|
||||||
|
path: &[(&str, &str)],
|
||||||
|
headers: &axum::http::HeaderMap,
|
||||||
|
) -> Result<Args, ApiError> {
|
||||||
|
let mut args = Args::default();
|
||||||
|
let mut errors = vec![];
|
||||||
|
for p in spec {
|
||||||
|
let raw: Option<String> = match p.loc {
|
||||||
|
In::Query => query.get(p.name).cloned(),
|
||||||
|
In::Path => path
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| *k == p.name)
|
||||||
|
.map(|(_, v)| v.to_string()),
|
||||||
|
In::Header => headers
|
||||||
|
.get(p.name)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(String::from),
|
||||||
|
};
|
||||||
|
let Some(raw) = raw else {
|
||||||
|
if p.required {
|
||||||
|
errors.push(error(
|
||||||
|
"missing",
|
||||||
|
p,
|
||||||
|
"Field required".into(),
|
||||||
|
Value::Null,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let v = match p.default {
|
||||||
|
Default::Int(d) => Val::Int(d),
|
||||||
|
Default::Str(d) => Val::Str(d.into()),
|
||||||
|
Default::None => Val::None,
|
||||||
|
};
|
||||||
|
args.0.insert(p.name, v);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let v = match p.kind {
|
||||||
|
Kind::Str => Val::Str(raw),
|
||||||
|
Kind::Int => match raw.trim().parse::<i64>() {
|
||||||
|
Err(_) => {
|
||||||
|
errors.push(error(
|
||||||
|
"int_parsing",
|
||||||
|
p,
|
||||||
|
"Input should be a valid integer, unable to parse string as an integer"
|
||||||
|
.into(),
|
||||||
|
json!(raw),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(v) => {
|
||||||
|
if let Some(ge) = p.ge
|
||||||
|
&& v < ge
|
||||||
|
{
|
||||||
|
errors.push(error(
|
||||||
|
"greater_than_equal",
|
||||||
|
p,
|
||||||
|
format!("Input should be greater than or equal to {ge}"),
|
||||||
|
json!(raw),
|
||||||
|
Some(json!({ "ge": ge })),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(le) = p.le
|
||||||
|
&& v > le
|
||||||
|
{
|
||||||
|
errors.push(error(
|
||||||
|
"less_than_equal",
|
||||||
|
p,
|
||||||
|
format!("Input should be less than or equal to {le}"),
|
||||||
|
json!(raw),
|
||||||
|
Some(json!({ "le": le })),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Val::Int(v)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Kind::Date => match parse_date(&raw) {
|
||||||
|
Ok(d) => Val::Date(d),
|
||||||
|
Err(why) => {
|
||||||
|
errors.push(error(
|
||||||
|
"date_from_datetime_parsing",
|
||||||
|
p,
|
||||||
|
format!("Input should be a valid date or datetime, {why}"),
|
||||||
|
json!(raw),
|
||||||
|
Some(json!({ "error": why })),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
args.0.insert(p.name, v);
|
||||||
|
}
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(args)
|
||||||
|
} else {
|
||||||
|
Err(ApiError::validation(errors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SPEC: &[Param] = &[
|
||||||
|
Param::path_str("station_code"),
|
||||||
|
Param::int("hours", 168).ge(1),
|
||||||
|
Param::int("limit", 50000).ge(1).le(100000),
|
||||||
|
Param::opt_date("start", "First day (overrides hours)"),
|
||||||
|
Param::opt_int("horizon").ge(1).le(48),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn run(q: &str) -> Result<Args, ApiError> {
|
||||||
|
parse(
|
||||||
|
SPEC,
|
||||||
|
&query_map(Some(q)),
|
||||||
|
&[("station_code", "P.1")],
|
||||||
|
&axum::http::HeaderMap::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_and_values() {
|
||||||
|
let a = run("hours=6&start=2026-10-15&hours=12").unwrap();
|
||||||
|
assert_eq!(a.int("hours"), 12); // last wins
|
||||||
|
assert_eq!(a.int("limit"), 50000);
|
||||||
|
assert_eq!(a.str("station_code"), "P.1");
|
||||||
|
assert_eq!(a.date("start"), NaiveDate::from_ymd_opt(2026, 10, 15));
|
||||||
|
assert_eq!(a.opt_int("horizon"), None);
|
||||||
|
assert_eq!(a.py_str("horizon"), "None");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_like_fastapi() {
|
||||||
|
// FastAPI: {"detail":[{"type":"greater_than_equal","loc":["query","hours"],
|
||||||
|
// "msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}
|
||||||
|
let e = run("hours=0&limit=x").unwrap_err();
|
||||||
|
assert_eq!(e.status.as_u16(), 422);
|
||||||
|
assert_eq!(
|
||||||
|
e.detail,
|
||||||
|
json!([
|
||||||
|
{"type": "greater_than_equal", "loc": ["query", "hours"],
|
||||||
|
"msg": "Input should be greater than or equal to 1", "input": "0", "ctx": {"ge": 1}},
|
||||||
|
{"type": "int_parsing", "loc": ["query", "limit"],
|
||||||
|
"msg": "Input should be a valid integer, unable to parse string as an integer",
|
||||||
|
"input": "x"}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openapi_parameter_objects() {
|
||||||
|
assert_eq!(
|
||||||
|
SPEC[2].openapi().to_string(),
|
||||||
|
r#"{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100000,"minimum":1,"default":50000,"title":"Limit"}}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SPEC[4].openapi().to_string(),
|
||||||
|
r#"{"name":"horizon","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":48,"minimum":1},{"type":"null"}],"title":"Horizon"}}"#
|
||||||
|
);
|
||||||
|
assert_eq!(Param::api_key().title(), "X-Api-Key");
|
||||||
|
assert_eq!(python_title("station code"), "Station Code");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! Output conventions of the Python app that the API keeps: `isoformat()`,
|
||||||
|
//! `round()`, numpy's summation order and Python's `str()` of JSON scalars.
|
||||||
|
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, Timelike};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// `datetime.isoformat()`: microseconds only when they are not zero.
|
||||||
|
pub fn isoformat(t: NaiveDateTime) -> String {
|
||||||
|
let base = t.format("%Y-%m-%dT%H:%M:%S");
|
||||||
|
match t.nanosecond() / 1000 {
|
||||||
|
0 => base.to_string(),
|
||||||
|
us => format!("{base}.{us:06}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `str(datetime)`: as `isoformat()` with a space instead of the `T`.
|
||||||
|
pub fn py_str_datetime(t: NaiveDateTime) -> String {
|
||||||
|
isoformat(t).replacen('T', " ", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `datetime.combine(d, datetime.max.time())`
|
||||||
|
pub fn end_of_day(d: NaiveDate) -> NaiveDateTime {
|
||||||
|
d.and_hms_micro_opt(23, 59, 59, 999_999)
|
||||||
|
.expect("valid time")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_of_day(d: NaiveDate) -> NaiveDateTime {
|
||||||
|
d.and_hms_opt(0, 0, 0).expect("valid time")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `round(x, ndigits)` for floats: the correctly rounded decimal, ties to
|
||||||
|
/// even (Rust's fixed-precision formatting rounds the same way).
|
||||||
|
pub fn py_round(x: f64, ndigits: usize) -> f64 {
|
||||||
|
if !x.is_finite() {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
format!("{x:.ndigits$}")
|
||||||
|
.parse()
|
||||||
|
.expect("formatted float parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// numpy's `np.add.reduce` on a contiguous float64 array (pairwise
|
||||||
|
/// summation, 8 accumulators per block of up to 128), which pandas'
|
||||||
|
/// `Series.mean()` uses without bottleneck.
|
||||||
|
pub fn numpy_sum(a: &[f64]) -> f64 {
|
||||||
|
0.0 + pairwise(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pairwise(a: &[f64]) -> f64 {
|
||||||
|
let n = a.len();
|
||||||
|
if n < 8 {
|
||||||
|
let mut res = -0.0;
|
||||||
|
for x in a {
|
||||||
|
res += x;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
} else if n <= 128 {
|
||||||
|
let mut r = [0.0; 8];
|
||||||
|
r.copy_from_slice(&a[..8]);
|
||||||
|
let mut i = 8;
|
||||||
|
while i < n - (n % 8) {
|
||||||
|
for j in 0..8 {
|
||||||
|
r[j] += a[i + j];
|
||||||
|
}
|
||||||
|
i += 8;
|
||||||
|
}
|
||||||
|
let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
|
||||||
|
while i < n {
|
||||||
|
res += a[i];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
} else {
|
||||||
|
let mut n2 = n / 2;
|
||||||
|
n2 -= n2 % 8;
|
||||||
|
pairwise(&a[..n2]) + pairwise(&a[n2..])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Series.mean()` (NaN-free input).
|
||||||
|
pub fn numpy_mean(a: &[f64]) -> f64 {
|
||||||
|
numpy_sum(a) / a.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `str(x)` of a JSON scalar as Python would print the decoded value.
|
||||||
|
pub fn py_str(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::Null => "None".into(),
|
||||||
|
Value::Bool(true) => "True".into(),
|
||||||
|
Value::Bool(false) => "False".into(),
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A float as JSON (`NaN` and infinities, which Python refuses to encode,
|
||||||
|
/// become null).
|
||||||
|
pub fn num(x: Option<f64>) -> Value {
|
||||||
|
x.filter(|x| x.is_finite()).map_or(Value::Null, Value::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn isoformat_drops_zero_microseconds() {
|
||||||
|
let d = NaiveDate::from_ymd_opt(2026, 10, 18).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
isoformat(d.and_hms_opt(14, 0, 0).unwrap()),
|
||||||
|
"2026-10-18T14:00:00"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
isoformat(d.and_hms_micro_opt(15, 30, 38, 454_399).unwrap()),
|
||||||
|
"2026-10-18T15:30:38.454399"
|
||||||
|
);
|
||||||
|
assert_eq!(isoformat(end_of_day(d)), "2026-10-18T23:59:59.999999");
|
||||||
|
assert_eq!(
|
||||||
|
py_str_datetime(d.and_hms_opt(21, 0, 0).unwrap()),
|
||||||
|
"2026-10-18 21:00:00"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounding_matches_python() {
|
||||||
|
// values from CPython 3.11: round(x, n)
|
||||||
|
for (x, n, want) in [
|
||||||
|
(0.125, 2, 0.12),
|
||||||
|
(0.375, 2, 0.38),
|
||||||
|
(2.675, 2, 2.67),
|
||||||
|
(1.0005, 3, 1.0),
|
||||||
|
(0.0875, 3, 0.087),
|
||||||
|
(-0.0049, 2, -0.0),
|
||||||
|
(93.35, 1, 93.3),
|
||||||
|
(24.6, 2, 24.6),
|
||||||
|
(1.2345e-7, 3, 0.0),
|
||||||
|
] {
|
||||||
|
assert_eq!(py_round(x, n), want, "round({x}, {n})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numpy_pairwise_sum_order() {
|
||||||
|
// np.add.reduce / pd.Series.mean (numpy 1.26), where a naive loop
|
||||||
|
// gives 20.000000000000014, 1027.6428571428573, 1481.0200000000002
|
||||||
|
assert_eq!(numpy_sum(&[0.1; 200]), 20.0);
|
||||||
|
let series = |n: usize| -> Vec<f64> {
|
||||||
|
(0..n)
|
||||||
|
.map(|i| ((i * 7919) % 1000) as f64 / 7.0 * 0.013 + 0.1)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!(numpy_sum(&series(1000)), 1027.642857142857);
|
||||||
|
assert_eq!(numpy_sum(&series(1441)), 1481.02);
|
||||||
|
assert_eq!(numpy_mean(&series(1441)), 1.0277723802914642);
|
||||||
|
assert_eq!(numpy_sum(&series(300)), 308.84999999999997);
|
||||||
|
assert_eq!(numpy_sum(&[]), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
//! The dashboard, health, metrics, collector status and the admin endpoints.
|
||||||
|
|
||||||
|
use super::Shared;
|
||||||
|
use super::error::{ApiError, ApiResult, ok, ok_json, to_body};
|
||||||
|
use super::params::{self, Param};
|
||||||
|
use super::pyfmt::{isoformat, py_str_datetime};
|
||||||
|
use crate::stations::StationInfo;
|
||||||
|
use crate::util::bangkok_now;
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// GET /: the dashboard (src/static/dashboard.html, loaded at start).
|
||||||
|
pub async fn root(State(st): State<Shared>) -> Response {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
st.dashboard.clone(),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn static_file(st: &Shared, name: &str, content_type: &'static str) -> Response {
|
||||||
|
match tokio::fs::read(st.cfg.web.static_dir.join(name)).await {
|
||||||
|
Ok(bytes) => ([(header::CONTENT_TYPE, content_type)], bytes).into_response(),
|
||||||
|
Err(_) => ApiError::new(404, "Not Found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crawler/indexing files at the domain root.
|
||||||
|
pub async fn robots(State(st): State<Shared>) -> Response {
|
||||||
|
static_file(&st, "robots.txt", "text/plain; charset=utf-8").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn llms(State(st): State<Shared>) -> Response {
|
||||||
|
static_file(&st, "llms.txt", "text/plain; charset=utf-8").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sitemap(State(st): State<Shared>) -> Response {
|
||||||
|
static_file(&st, "sitemap.xml", "application/xml").await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- /health
|
||||||
|
|
||||||
|
struct Check {
|
||||||
|
status: &'static str,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(status: &'static str, message: impl Into<String>) -> Check {
|
||||||
|
Check {
|
||||||
|
status,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data freshness. As the Python check it judges the newest reading of the
|
||||||
|
/// first station (`get_latest_measurements(limit=1)`: P.1) against 2 h. One
|
||||||
|
/// change, as in the notifier: while RID's report lags (2-10 h at night) but
|
||||||
|
/// the ThaiWater telemetry of the same gauges is current, the data is current.
|
||||||
|
async fn database_check(st: &Shared) -> Check {
|
||||||
|
let latest: Result<Option<(chrono::NaiveDateTime,)>, _> = sqlx::query_as(
|
||||||
|
"SELECT m.timestamp
|
||||||
|
FROM water_measurements m
|
||||||
|
JOIN stations s ON m.station_id = s.id
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT station_id, MAX(timestamp) as max_timestamp
|
||||||
|
FROM water_measurements GROUP BY station_id
|
||||||
|
) latest ON m.station_id = latest.station_id AND m.timestamp = latest.max_timestamp
|
||||||
|
ORDER BY s.station_code LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&st.pool)
|
||||||
|
.await;
|
||||||
|
let latest = match latest {
|
||||||
|
Ok(l) => l.map(|(t,)| t),
|
||||||
|
Err(e) => return check("unhealthy", format!("Database check failed: {e}")),
|
||||||
|
};
|
||||||
|
let now = bangkok_now();
|
||||||
|
let max_age = chrono::TimeDelta::hours(2);
|
||||||
|
if let Some(ts) = latest
|
||||||
|
&& now - ts > max_age
|
||||||
|
{
|
||||||
|
let codes = &ping_forecast::config::config().hii_fill_stations;
|
||||||
|
if let Ok(Some(hii)) = crate::db::latest_hii_timestamp(&st.pool, codes).await
|
||||||
|
&& now - hii <= max_age
|
||||||
|
{
|
||||||
|
return check(
|
||||||
|
"healthy",
|
||||||
|
format!(
|
||||||
|
"Database connection OK; RID report lags (latest {}), ThaiWater telemetry current (latest {})",
|
||||||
|
py_str_datetime(ts),
|
||||||
|
py_str_datetime(hii)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return check(
|
||||||
|
"degraded",
|
||||||
|
format!("Latest data is old: {}", py_str_datetime(ts)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check("healthy", "Database connection OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The RID API answers (5 s, a liveness probe must fail fast).
|
||||||
|
async fn api_check(st: &Shared) -> Check {
|
||||||
|
match st
|
||||||
|
.http
|
||||||
|
.get(&st.cfg.rid_api_url)
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.status() == StatusCode::OK => check("healthy", "API connection OK"),
|
||||||
|
Ok(r) => check(
|
||||||
|
"degraded",
|
||||||
|
format!("API returned status {}", r.status().as_u16()),
|
||||||
|
),
|
||||||
|
Err(e) => check("unhealthy", format!("API check failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resident memory of this process in MB (Linux).
|
||||||
|
pub fn rss_mb() -> Option<f64> {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||||
|
let kb: f64 = status
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("VmRSS:"))?
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)?
|
||||||
|
.parse()
|
||||||
|
.ok()?;
|
||||||
|
Some(kb / 1024.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn memory_check() -> Check {
|
||||||
|
match rss_mb() {
|
||||||
|
Some(mb) if mb > 1000.0 => check("degraded", format!("High memory usage: {mb:.1}MB")),
|
||||||
|
Some(mb) => check("healthy", format!("Memory usage OK: {mb:.1}MB")),
|
||||||
|
None => check("healthy", "Memory check skipped (no /proc/self/status)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn timed<F: std::future::Future<Output = Check>>(
|
||||||
|
f: F,
|
||||||
|
) -> (Check, f64, chrono::NaiveDateTime) {
|
||||||
|
let t = Instant::now();
|
||||||
|
let c = f.await;
|
||||||
|
(c, t.elapsed().as_secs_f64() * 1000.0, bangkok_now())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_health(st: Shared) -> super::cache::Computed<Bytes> {
|
||||||
|
let results = [
|
||||||
|
("database", timed(database_check(&st)).await),
|
||||||
|
("api", timed(api_check(&st)).await),
|
||||||
|
("memory", timed(memory_check()).await),
|
||||||
|
];
|
||||||
|
let statuses: Vec<&str> = results.iter().map(|(_, (c, _, _))| c.status).collect();
|
||||||
|
let overall = if statuses.contains(&"unhealthy") {
|
||||||
|
"unhealthy"
|
||||||
|
} else if statuses.contains(&"degraded") {
|
||||||
|
"degraded"
|
||||||
|
} else {
|
||||||
|
"healthy"
|
||||||
|
};
|
||||||
|
let mut checks = Map::new();
|
||||||
|
for (name, (c, ms, at)) in results {
|
||||||
|
checks.insert(
|
||||||
|
name.into(),
|
||||||
|
json!({
|
||||||
|
"status": c.status,
|
||||||
|
"message": c.message,
|
||||||
|
"response_time_ms": ms,
|
||||||
|
"timestamp": isoformat(at),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let body = json!({
|
||||||
|
"overall_status": overall,
|
||||||
|
"timestamp": isoformat(bangkok_now()),
|
||||||
|
"checks": checks,
|
||||||
|
});
|
||||||
|
Ok((to_body(&body), true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /health: cached briefly (it probes the RID API), never older than 4 TTLs.
|
||||||
|
pub async fn health(State(st): State<Shared>) -> ApiResult {
|
||||||
|
st.metrics.count("health");
|
||||||
|
let ttl = st.cfg.web.health_cache_ttl_s;
|
||||||
|
let me = st.clone();
|
||||||
|
let (body, _) = st
|
||||||
|
.caches
|
||||||
|
.health
|
||||||
|
.get(
|
||||||
|
"health".into(),
|
||||||
|
Duration::from_secs(ttl),
|
||||||
|
Some(Duration::from_secs(4 * ttl)),
|
||||||
|
move || compute_health(me.clone()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ok(body, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /metrics
|
||||||
|
pub async fn metrics(State(st): State<Shared>) -> ApiResult {
|
||||||
|
st.metrics.count("metrics");
|
||||||
|
let mut gauges = Map::new();
|
||||||
|
gauges.insert(
|
||||||
|
"uptime_seconds".into(),
|
||||||
|
json!(st.started.elapsed().as_secs_f64()),
|
||||||
|
);
|
||||||
|
if let Some(mb) = rss_mb() {
|
||||||
|
gauges.insert("memory_rss_mb".into(), json!(mb));
|
||||||
|
}
|
||||||
|
gauges.insert("db_pool_connections".into(), json!(st.pool.size() as f64));
|
||||||
|
ok_json(&json!({
|
||||||
|
"counters": st.metrics.counters(),
|
||||||
|
"gauges": gauges,
|
||||||
|
"histograms": {},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- stations
|
||||||
|
|
||||||
|
fn station_json(id: u32, s: &StationInfo, geohash: bool, status: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"station_id": id,
|
||||||
|
"station_code": s.code,
|
||||||
|
"thai_name": s.thai_name,
|
||||||
|
"english_name": s.english_name,
|
||||||
|
"latitude": s.latitude,
|
||||||
|
"longitude": s.longitude,
|
||||||
|
"geohash": if geohash { json!(s.geohash) } else { Value::Null },
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /stations: the RID gauges (report column -> station).
|
||||||
|
pub async fn stations(State(st): State<Shared>) -> ApiResult {
|
||||||
|
st.metrics.count("stations");
|
||||||
|
let list: Vec<Value> = st
|
||||||
|
.stations
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, s)| station_json(*id, s, false, "active"))
|
||||||
|
.collect();
|
||||||
|
ok_json(&list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const STATION_ID: &[Param] = &[Param::path_int("station_id")];
|
||||||
|
pub const ADMIN_STATION_ID: &[Param] = &[Param::path_int("station_id"), Param::api_key()];
|
||||||
|
pub const ADMIN: &[Param] = &[Param::api_key()];
|
||||||
|
|
||||||
|
fn station_id(id: &str, h: &HeaderMap) -> Result<u32, ApiError> {
|
||||||
|
let a = params::parse(STATION_ID, &Default::default(), &[("station_id", id)], h)?;
|
||||||
|
// ids beyond u32 cannot exist
|
||||||
|
Ok(u32::try_from(a.int("station_id")).unwrap_or(u32::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found() -> ApiError {
|
||||||
|
ApiError::new(404, "Station not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /stations/{station_id}
|
||||||
|
pub async fn station(State(st): State<Shared>, Path(id): Path<String>, h: HeaderMap) -> ApiResult {
|
||||||
|
st.metrics.count("get_station");
|
||||||
|
let id = station_id(&id, &h)?;
|
||||||
|
let stations = st.stations.read().unwrap();
|
||||||
|
let s = stations.info(id).ok_or_else(not_found)?;
|
||||||
|
ok_json(&station_json(id, s, true, "active"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `require_admin_key`: the X-API-Key header must match ADMIN_API_KEY;
|
||||||
|
/// with no key configured the admin endpoints are disabled.
|
||||||
|
fn require_admin(st: &Shared, h: &HeaderMap) -> Result<(), ApiError> {
|
||||||
|
let Some(want) = &st.cfg.web.admin_api_key else {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
503,
|
||||||
|
"Admin API disabled: ADMIN_API_KEY is not configured on the server",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let given = h.get("x-api-key").map(|v| v.as_bytes()).unwrap_or_default();
|
||||||
|
// constant time, as secrets.compare_digest
|
||||||
|
let same = given.len() == want.len()
|
||||||
|
&& given
|
||||||
|
.iter()
|
||||||
|
.zip(want.as_bytes())
|
||||||
|
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||||
|
== 0;
|
||||||
|
if same {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::new(401, "Invalid or missing X-API-Key"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_object(body: &Bytes) -> Result<Map<String, Value>, ApiError> {
|
||||||
|
match serde_json::from_slice::<Value>(body) {
|
||||||
|
Ok(Value::Object(m)) => Ok(m),
|
||||||
|
Ok(other) => Err(ApiError::validation(vec![json!({
|
||||||
|
"type": "model_attributes_type", "loc": ["body"],
|
||||||
|
"msg": "Input should be a valid dictionary or object to extract fields from",
|
||||||
|
"input": other,
|
||||||
|
})])),
|
||||||
|
Err(e) => Err(ApiError::validation(vec![json!({
|
||||||
|
"type": "json_invalid", "loc": ["body", e.column()],
|
||||||
|
"msg": "JSON decode error", "input": {}, "ctx": {"error": e.to_string()},
|
||||||
|
})])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Field checks of StationCreateModel / StationUpdateModel.
|
||||||
|
struct Fields<'a> {
|
||||||
|
body: &'a Map<String, Value>,
|
||||||
|
errors: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Fields<'a> {
|
||||||
|
fn err(&mut self, kind: &str, field: &str, msg: String, ctx: Option<Value>) {
|
||||||
|
let mut e = json!({
|
||||||
|
"type": kind, "loc": ["body", field], "msg": msg,
|
||||||
|
"input": self.body.get(field).cloned().unwrap_or(Value::Object(self.body.clone())),
|
||||||
|
});
|
||||||
|
if let Some(c) = ctx {
|
||||||
|
e["ctx"] = c;
|
||||||
|
}
|
||||||
|
self.errors.push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(&mut self, field: &str, required: bool) -> Option<String> {
|
||||||
|
match self.body.get(field) {
|
||||||
|
None if required => {
|
||||||
|
self.err("missing", field, "Field required".into(), None);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None | Some(Value::Null) if !required => None,
|
||||||
|
Some(Value::String(s)) => Some(s.clone()),
|
||||||
|
_ => {
|
||||||
|
self.err(
|
||||||
|
"string_type",
|
||||||
|
field,
|
||||||
|
"Input should be a valid string".into(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coord(&mut self, field: &str, limit: f64) -> Option<f64> {
|
||||||
|
let v = match self.body.get(field) {
|
||||||
|
None | Some(Value::Null) => return None,
|
||||||
|
Some(Value::Number(n)) => n.as_f64(),
|
||||||
|
Some(Value::String(s)) => s.trim().parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let Some(x) = v else {
|
||||||
|
self.err(
|
||||||
|
"float_parsing",
|
||||||
|
field,
|
||||||
|
"Input should be a valid number, unable to parse string as a number".into(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if x < -limit {
|
||||||
|
self.err(
|
||||||
|
"greater_than_equal",
|
||||||
|
field,
|
||||||
|
format!("Input should be greater than or equal to {}", -limit),
|
||||||
|
Some(json!({"ge": -limit})),
|
||||||
|
);
|
||||||
|
} else if x > limit {
|
||||||
|
self.err(
|
||||||
|
"less_than_equal",
|
||||||
|
field,
|
||||||
|
format!("Input should be less than or equal to {limit}"),
|
||||||
|
Some(json!({"le": limit})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) -> Result<(), ApiError> {
|
||||||
|
if self.errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::validation(self.errors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(st: &Shared) -> bool {
|
||||||
|
let stations = st.stations.read().unwrap();
|
||||||
|
match stations.save(&st.cfg.station_config_path) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to persist station mapping to {}: {e:#}",
|
||||||
|
st.cfg.station_config_path.display()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /stations (admin)
|
||||||
|
pub async fn create_station(State(st): State<Shared>, h: HeaderMap, body: Bytes) -> ApiResult {
|
||||||
|
st.metrics.count("create_station");
|
||||||
|
require_admin(&st, &h)?;
|
||||||
|
let body = body_object(&body)?;
|
||||||
|
let mut f = Fields {
|
||||||
|
body: &body,
|
||||||
|
errors: vec![],
|
||||||
|
};
|
||||||
|
let code = f.string("station_code", true);
|
||||||
|
let thai = f.string("thai_name", true);
|
||||||
|
let english = f.string("english_name", true);
|
||||||
|
let latitude = f.coord("latitude", 90.0);
|
||||||
|
let longitude = f.coord("longitude", 180.0);
|
||||||
|
let geohash = f.string("geohash", false);
|
||||||
|
let status = f.string("status", false).unwrap_or_else(|| "active".into());
|
||||||
|
f.finish()?;
|
||||||
|
let info = StationInfo {
|
||||||
|
code: code.unwrap_or_default(),
|
||||||
|
thai_name: thai.unwrap_or_default(),
|
||||||
|
english_name: english.unwrap_or_default(),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
geohash,
|
||||||
|
};
|
||||||
|
let id = {
|
||||||
|
let mut stations = st.stations.write().unwrap();
|
||||||
|
let id = stations.next_id();
|
||||||
|
stations.insert(id, info.clone());
|
||||||
|
id
|
||||||
|
};
|
||||||
|
if !persist(&st) {
|
||||||
|
st.stations.write().unwrap().remove(id);
|
||||||
|
return Err(ApiError::new(500, "Failed to persist new station"));
|
||||||
|
}
|
||||||
|
tracing::info!("Created new station: {} ({})", info.code, info.english_name);
|
||||||
|
ok_json(&station_json(id, &info, true, &status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /stations/{station_id} (admin)
|
||||||
|
pub async fn update_station(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
h: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> ApiResult {
|
||||||
|
st.metrics.count("update_station");
|
||||||
|
require_admin(&st, &h)?;
|
||||||
|
let id = station_id(&id, &h)?;
|
||||||
|
let body = body_object(&body)?;
|
||||||
|
let mut f = Fields {
|
||||||
|
body: &body,
|
||||||
|
errors: vec![],
|
||||||
|
};
|
||||||
|
let thai = f.string("thai_name", false);
|
||||||
|
let english = f.string("english_name", false);
|
||||||
|
let latitude = f.coord("latitude", 90.0);
|
||||||
|
let longitude = f.coord("longitude", 180.0);
|
||||||
|
let geohash = f.string("geohash", false);
|
||||||
|
let status = f.string("status", false);
|
||||||
|
f.finish()?;
|
||||||
|
let (original, updated) = {
|
||||||
|
let mut stations = st.stations.write().unwrap();
|
||||||
|
let s = stations.info_mut(id).ok_or_else(not_found)?;
|
||||||
|
let original = s.clone();
|
||||||
|
if let Some(v) = thai {
|
||||||
|
s.thai_name = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = english {
|
||||||
|
s.english_name = v;
|
||||||
|
}
|
||||||
|
if latitude.is_some() {
|
||||||
|
s.latitude = latitude;
|
||||||
|
}
|
||||||
|
if longitude.is_some() {
|
||||||
|
s.longitude = longitude;
|
||||||
|
}
|
||||||
|
if geohash.is_some() {
|
||||||
|
s.geohash = geohash;
|
||||||
|
}
|
||||||
|
(original, s.clone())
|
||||||
|
};
|
||||||
|
if !persist(&st) {
|
||||||
|
st.stations.write().unwrap().insert(id, original);
|
||||||
|
return Err(ApiError::new(500, "Failed to persist station update"));
|
||||||
|
}
|
||||||
|
tracing::info!("Updated station {id}: {}", updated.code);
|
||||||
|
ok_json(&station_json(
|
||||||
|
id,
|
||||||
|
&updated,
|
||||||
|
true,
|
||||||
|
status
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("active"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /stations/{station_id} (admin)
|
||||||
|
pub async fn delete_station(
|
||||||
|
State(st): State<Shared>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
h: HeaderMap,
|
||||||
|
) -> ApiResult {
|
||||||
|
st.metrics.count("delete_station");
|
||||||
|
require_admin(&st, &h)?;
|
||||||
|
let id = station_id(&id, &h)?;
|
||||||
|
let removed = st
|
||||||
|
.stations
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.remove(id)
|
||||||
|
.ok_or_else(not_found)?;
|
||||||
|
if !persist(&st) {
|
||||||
|
st.stations.write().unwrap().insert(id, removed);
|
||||||
|
return Err(ApiError::new(500, "Failed to persist station deletion"));
|
||||||
|
}
|
||||||
|
tracing::info!("Deleted station {id}: {}", removed.code);
|
||||||
|
ok_json(&json!({"message": format!("Station {} deleted successfully", removed.code)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ collection
|
||||||
|
|
||||||
|
/// GET /scraping/status: the collector's last cycle (in this process, or
|
||||||
|
/// SCRAPING_STATUS_FILE when serving alone).
|
||||||
|
pub async fn scraping_status(State(st): State<Shared>) -> ApiResult {
|
||||||
|
st.metrics.count("scraping_status");
|
||||||
|
let status: Value = match &st.status {
|
||||||
|
Some(s) => s.read().unwrap().clone(),
|
||||||
|
None => st
|
||||||
|
.cfg
|
||||||
|
.status_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.and_then(|t| serde_json::from_str(&t).ok())
|
||||||
|
.unwrap_or_else(|| json!({"is_running": false})),
|
||||||
|
};
|
||||||
|
// ScrapingStatusResponse's fields and defaults first, then what the
|
||||||
|
// collector adds (version, forecast run)
|
||||||
|
let defaults = [
|
||||||
|
("is_running", json!(false)),
|
||||||
|
("last_run", Value::Null),
|
||||||
|
("next_run", Value::Null),
|
||||||
|
("total_runs", json!(0)),
|
||||||
|
("successful_runs", json!(0)),
|
||||||
|
("failed_runs", json!(0)),
|
||||||
|
("feed_current", Value::Null),
|
||||||
|
("latest_reading", Value::Null),
|
||||||
|
("readings_saved", json!(0)),
|
||||||
|
];
|
||||||
|
let mut out = Map::new();
|
||||||
|
for (k, d) in defaults {
|
||||||
|
out.insert(k.into(), status.get(k).cloned().unwrap_or(d));
|
||||||
|
}
|
||||||
|
for (k, v) in status.as_object().into_iter().flatten() {
|
||||||
|
if !out.contains_key(k) {
|
||||||
|
out.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok_json(&Value::Object(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /scrape/trigger (admin): poll every source now.
|
||||||
|
pub async fn trigger(State(st): State<Shared>, h: HeaderMap) -> ApiResult {
|
||||||
|
st.metrics.count("scrape_trigger");
|
||||||
|
require_admin(&st, &h)?;
|
||||||
|
let Some(trigger) = &st.trigger else {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
409,
|
||||||
|
"Collection does not run in this process (ping-collector serve)",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let running = st
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| s.read().unwrap()["is_running"] == true);
|
||||||
|
if running {
|
||||||
|
return Err(ApiError::new(409, "Scraping already in progress"));
|
||||||
|
}
|
||||||
|
trigger.notify_one();
|
||||||
|
ok_json(&json!({"message": "Scraping triggered", "status": "started"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /config (admin): the settings, secrets masked.
|
||||||
|
pub async fn config(State(st): State<Shared>, h: HeaderMap) -> ApiResult {
|
||||||
|
st.metrics.count("config");
|
||||||
|
require_admin(&st, &h)?;
|
||||||
|
let c = &st.cfg;
|
||||||
|
let w = &c.web;
|
||||||
|
let mut out = json!({
|
||||||
|
"DB_TYPE": "postgresql",
|
||||||
|
"DATABASE_URL": c.redacted_database_url(),
|
||||||
|
"API_URL": c.rid_api_url,
|
||||||
|
"REQUEST_TIMEOUT": w.request_timeout_s,
|
||||||
|
"RID_POLL_OFFSET_MINUTES": c.poll_offset_minutes,
|
||||||
|
"RID_RETRY_MINUTES": c.retry_minutes,
|
||||||
|
"GAP_FILL_DAYS_ON_START": c.gap_fill_days_on_start,
|
||||||
|
"ENABLE_HII_COLLECTION": c.hii_enabled,
|
||||||
|
"ENABLE_RESERVOIR_COLLECTION": c.dams_enabled,
|
||||||
|
"ENABLE_RAIN_COLLECTION": c.rain_enabled,
|
||||||
|
"FORECAST_IN_COLLECTOR": c.forecast_enabled,
|
||||||
|
"MODELS_DIR": c.models_dir.display().to_string(),
|
||||||
|
"STATION_CONFIG_PATH": c.station_config_path.display().to_string(),
|
||||||
|
"SCRAPING_STATUS_FILE": c.status_file.as_ref().map(|p| p.display().to_string()),
|
||||||
|
"NTFY_SERVER": c.ntfy_server,
|
||||||
|
"NTFY_TOPIC_PREFIX": c.ntfy_topic_prefix,
|
||||||
|
"MATRIX_ENABLED": c.matrix.is_some(),
|
||||||
|
"WEB_LISTEN": w.listen,
|
||||||
|
"STATIC_DIR": w.static_dir.display().to_string(),
|
||||||
|
"CORS_ALLOW_ORIGINS": w.cors_allow_origins,
|
||||||
|
"HII_CACHE_TTL_SECONDS": w.hii_cache_ttl_s,
|
||||||
|
"LATEST_CACHE_TTL_SECONDS": w.latest_cache_ttl_s,
|
||||||
|
"HEALTH_CACHE_TTL_SECONDS": w.health_cache_ttl_s,
|
||||||
|
"FORECAST_MAX_AGE_SECONDS": w.forecast_max_age_s,
|
||||||
|
"UMAMI_TRACK_API": w.umami_track_api,
|
||||||
|
"collector": concat!("ping-collector ", env!("CARGO_PKG_VERSION")),
|
||||||
|
});
|
||||||
|
if let Some(m) = out.as_object_mut() {
|
||||||
|
for (k, v) in m.iter_mut() {
|
||||||
|
let k = k.to_lowercase();
|
||||||
|
if (k.contains("password") || k.contains("secret")) && !v.is_null() {
|
||||||
|
*v = json!("********");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok_json(&out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
//! Route tests: the router as the server runs it, against a throwaway
|
||||||
|
//! schema (TEST_DATABASE_URL; see db_tests.rs) or, for the routes that do
|
||||||
|
//! not touch the database, a pool that never connects. Output formats are
|
||||||
|
//! the FastAPI app's (scripts/api_parity.py compares the two live).
|
||||||
|
|
||||||
|
use super::{AppState, router};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::db_tests::TestDb;
|
||||||
|
use crate::stations::Stations;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{HeaderMap, Request, StatusCode};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn config(extra: &[(&str, &str)]) -> Config {
|
||||||
|
let static_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../src/static");
|
||||||
|
let mut vars: HashMap<String, String> = [
|
||||||
|
(
|
||||||
|
"POSTGRES_CONNECTION_STRING",
|
||||||
|
"postgres://test@localhost:1/none",
|
||||||
|
),
|
||||||
|
("UMAMI_TRACK_API", "false"),
|
||||||
|
("STATIC_DIR", static_dir),
|
||||||
|
("NTFY_SERVER", "https://ntfy.example"),
|
||||||
|
("STATION_CONFIG_PATH", "/nonexistent/stations.json"),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect();
|
||||||
|
for (k, v) in extra {
|
||||||
|
vars.insert(k.to_string(), v.to_string());
|
||||||
|
}
|
||||||
|
Config::from_vars(&|k| vars.get(k).cloned()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app(cfg: Config, pool: sqlx::PgPool) -> Router {
|
||||||
|
let state = AppState::new(
|
||||||
|
cfg,
|
||||||
|
pool,
|
||||||
|
reqwest::Client::new(),
|
||||||
|
Arc::new(RwLock::new(Stations::bundled())),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
router(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(
|
||||||
|
app: &Router,
|
||||||
|
method: &str,
|
||||||
|
uri: &str,
|
||||||
|
headers: &[(&str, &str)],
|
||||||
|
) -> (StatusCode, HeaderMap, Vec<u8>) {
|
||||||
|
let mut req = Request::builder().method(method).uri(uri);
|
||||||
|
for (k, v) in headers {
|
||||||
|
req = req.header(*k, *v);
|
||||||
|
}
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(req.body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (parts, body) = resp.into_parts();
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||||
|
(parts.status, parts.headers, bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_json(app: &Router, uri: &str) -> (StatusCode, HeaderMap, Value) {
|
||||||
|
let (s, h, b) = call(app, "GET", uri, &[]).await;
|
||||||
|
let v = serde_json::from_slice(&b)
|
||||||
|
.unwrap_or_else(|e| panic!("{uri}: {e}: {}", String::from_utf8_lossy(&b)));
|
||||||
|
(s, h, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
|
h.get(name).and_then(|v| v.to_str().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn routes_without_database() {
|
||||||
|
let pool = sqlx::PgPool::connect_lazy("postgres://test@localhost:1/none").unwrap();
|
||||||
|
let app = app(config(&[]), pool);
|
||||||
|
|
||||||
|
let (s, h, stations) = get_json(&app, "/stations").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=300"));
|
||||||
|
assert_eq!(header(&h, "content-type"), Some("application/json"));
|
||||||
|
assert_eq!(stations.as_array().unwrap().len(), 16);
|
||||||
|
// the list leaves geohash out, the single station has it (as FastAPI)
|
||||||
|
assert_eq!(stations[7]["station_code"], "P.1");
|
||||||
|
assert_eq!(stations[7]["geohash"], Value::Null);
|
||||||
|
let (_, _, p1) = get_json(&app, "/stations/8").await;
|
||||||
|
assert_eq!(
|
||||||
|
p1.as_object().unwrap().keys().collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
"station_id",
|
||||||
|
"station_code",
|
||||||
|
"thai_name",
|
||||||
|
"english_name",
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
|
"geohash",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let (s, _, e) = get_json(&app, "/stations/abc").await;
|
||||||
|
assert_eq!(s, StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
assert_eq!(e["detail"][0]["type"], "int_parsing");
|
||||||
|
assert_eq!(e["detail"][0]["loc"], json!(["path", "station_id"]));
|
||||||
|
assert_eq!(
|
||||||
|
get_json(&app, "/stations/99").await.2,
|
||||||
|
json!({"detail": "Station not found"})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_json(&app, "/nope").await.2,
|
||||||
|
json!({"detail": "Not Found"})
|
||||||
|
);
|
||||||
|
let (s, _, b) = call(&app, "DELETE", "/forecast", &[]).await;
|
||||||
|
assert_eq!(s, StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
assert_eq!(b, br#"{"detail":"Method Not Allowed"}"#);
|
||||||
|
|
||||||
|
let (s, _, e) = get_json(&app, "/config").await;
|
||||||
|
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
assert_eq!(
|
||||||
|
e["detail"],
|
||||||
|
"Admin API disabled: ADMIN_API_KEY is not configured on the server"
|
||||||
|
);
|
||||||
|
let (s, _, e) = get_json(&app, "/measurements/history/P.1?hours=0").await;
|
||||||
|
assert_eq!(s, StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
assert_eq!(e["detail"][0]["ctx"], json!({"ge": 1}));
|
||||||
|
|
||||||
|
let (_, _, n) = get_json(&app, "/api/notifications").await;
|
||||||
|
assert_eq!(n["topics"]["warning"], "ping-warning");
|
||||||
|
assert_eq!(n["server"], "https://ntfy.example");
|
||||||
|
|
||||||
|
let (s, h, spec) = get_json(&app, "/openapi.json").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(header(&h, "content-encoding"), None);
|
||||||
|
assert!(spec["paths"]["/forecast"]["get"].is_object());
|
||||||
|
|
||||||
|
let (s, h, html) = call(&app, "GET", "/", &[("accept-encoding", "gzip")]).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=120"));
|
||||||
|
assert_eq!(header(&h, "content-encoding"), Some("gzip"));
|
||||||
|
assert!(html.len() > 100);
|
||||||
|
let (s, h, _) = call(&app, "GET", "/docs", &[]).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(header(&h, "content-type"), Some("text/html; charset=utf-8"));
|
||||||
|
let (s, h, _) = call(&app, "GET", "/static/custom-markers.json", &[]).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=3600"));
|
||||||
|
let (s, h, _) = call(&app, "GET", "/robots.txt", &[]).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
header(&h, "content-type"),
|
||||||
|
Some("text/plain; charset=utf-8")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "GET", "/static/nope.json", &[]).await.0,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
|
||||||
|
// admin key
|
||||||
|
let app = super::router(AppState::new(
|
||||||
|
config(&[("ADMIN_API_KEY", "k3y")]),
|
||||||
|
sqlx::PgPool::connect_lazy("postgres://test@localhost:1/none").unwrap(),
|
||||||
|
reqwest::Client::new(),
|
||||||
|
Arc::new(RwLock::new(Stations::bundled())),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "GET", "/config", &[("x-api-key", "nope")])
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
let (s, _, b) = call(&app, "GET", "/config", &[("x-api-key", "k3y")]).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{}", String::from_utf8_lossy(&b));
|
||||||
|
assert!(String::from_utf8_lossy(&b).contains("postgres://test:***@localhost:1/none"));
|
||||||
|
let (s, _, b) = call(&app, "POST", "/scrape/trigger", &[("x-api-key", "k3y")]).await;
|
||||||
|
assert_eq!(s, StatusCode::CONFLICT, "{}", String::from_utf8_lossy(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed(pool: &sqlx::PgPool) {
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO stations (id, station_code, thai_name, english_name) VALUES
|
||||||
|
(1, 'P.20', 'บ้านเชียงดาว', 'Ban Chiang Dao'), (8, 'P.1', 'สะพานนวรัฐ', 'Nawarat Bridge')",
|
||||||
|
// P.1 hourly 2026-10-01 00:00 .. 10-03 23:00, one missing discharge, a hole at 10-02 05:00
|
||||||
|
"INSERT INTO water_measurements (timestamp, station_id, water_level, discharge, discharge_percent, status)
|
||||||
|
SELECT t, 8, round((1.2 + extract(hour from t) / 100.0)::numeric, 3),
|
||||||
|
CASE WHEN t = timestamp '2026-10-02 03:00' THEN NULL ELSE 55.5 END, 12.94, 'active'
|
||||||
|
FROM generate_series(timestamp '2026-10-01 00:00', timestamp '2026-10-03 23:00', interval '1 hour') t
|
||||||
|
WHERE t <> timestamp '2026-10-02 05:00'",
|
||||||
|
"INSERT INTO water_measurements (timestamp, station_id, water_level, discharge, discharge_percent, status)
|
||||||
|
VALUES (timestamp '2026-10-03 22:00', 1, 0.95, NULL, NULL, 'active')",
|
||||||
|
"INSERT INTO forecast_history (as_of, station_code, horizon_hours, predicted_max_level, p_warning,
|
||||||
|
p_danger, current_level, model_version, source)
|
||||||
|
SELECT t, 'P.1', 6, 1.5, 0.01, 0.0, 1.2, CASE WHEN t < timestamp '2026-10-02 00:00' THEN 'v1' ELSE 'v2' END, 'model'
|
||||||
|
FROM generate_series(timestamp '2026-10-01 00:00', timestamp '2026-10-03 12:00', interval '1 hour') t",
|
||||||
|
"INSERT INTO forecast_current (id, computed_at, model_versions, payload)
|
||||||
|
VALUES ('all', timestamp '2026-10-03 23:10', 'v2', '[{\"station_code\":\"P.1\",\"horizon_hours\":6}]')",
|
||||||
|
] {
|
||||||
|
sqlx::query(sql).execute(pool).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn routes_against_the_database() {
|
||||||
|
let Some(db) = TestDb::new().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
seed(&db.pool).await;
|
||||||
|
let app = app(
|
||||||
|
config(&[("FORECAST_MAX_AGE_SECONDS", "60")]),
|
||||||
|
db.pool.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (s, h, latest) = get_json(&app, "/measurements/latest?limit=5").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(header(&h, "cache-control"), Some("public, max-age=30"));
|
||||||
|
// ORDER BY station_code: P.1 before P.20
|
||||||
|
assert_eq!(
|
||||||
|
latest,
|
||||||
|
json!([
|
||||||
|
{"timestamp": "2026-10-03T23:00:00", "station_code": "P.1", "station_name_en": "Nawarat Bridge",
|
||||||
|
"station_name_th": "สะพานนวรัฐ", "water_level": 1.43, "discharge": 55.5,
|
||||||
|
"discharge_percent": 12.94, "status": "active"},
|
||||||
|
{"timestamp": "2026-10-03T22:00:00", "station_code": "P.20", "station_name_en": "Ban Chiang Dao",
|
||||||
|
"station_name_th": "บ้านเชียงดาว", "water_level": 0.95, "discharge": null,
|
||||||
|
"discharge_percent": null, "status": "active"}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, _, h) = get_json(
|
||||||
|
&app,
|
||||||
|
"/measurements/history/P.1?start=2026-10-02&end=2026-10-02&limit=6",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let rows = h.as_array().unwrap();
|
||||||
|
assert_eq!(rows.len(), 6);
|
||||||
|
assert_eq!(rows[0]["timestamp"], "2026-10-02T00:00:00");
|
||||||
|
// discharge missing in the database: 20 * H, rounded
|
||||||
|
assert_eq!(rows[3]["discharge"], json!(24.6));
|
||||||
|
assert_eq!(rows[5]["timestamp"], "2026-10-02T06:00:00"); // the hole at 05:00
|
||||||
|
let (s, _, e) = get_json(
|
||||||
|
&app,
|
||||||
|
"/measurements/history/P.1?start=2026-10-03&end=2026-10-02",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_GATEWAY);
|
||||||
|
assert_eq!(e["detail"], "Measurement history unavailable");
|
||||||
|
|
||||||
|
let (_, _, stats) = get_json(&app, "/api/stats").await;
|
||||||
|
assert_eq!(stats["rid_measurements"], 72);
|
||||||
|
assert_eq!(stats["rid_station_count"], 2);
|
||||||
|
assert_eq!(stats["first_timestamp"], "2026-10-01T00:00:00");
|
||||||
|
assert_eq!(stats["days_spanned"], 3);
|
||||||
|
assert_eq!(stats["coverage_percent"], json!(98.6)); // 71 of 72 hours
|
||||||
|
|
||||||
|
let (_, _, fh) = get_json(&app, "/api/forecast/history/P.1?start=2026-10-03&horizon=6").await;
|
||||||
|
assert_eq!(fh.as_array().unwrap().len(), 13);
|
||||||
|
assert_eq!(fh[0]["predicted_max_level"], json!(1.5));
|
||||||
|
|
||||||
|
let (s, h, f) = get_json(&app, "/forecast").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(f[0]["station_code"], "P.1");
|
||||||
|
// computed long ago: served, marked
|
||||||
|
assert_eq!(header(&h, "x-data-stale"), Some("true"));
|
||||||
|
|
||||||
|
let (_, _, sk) = get_json(&app, "/api/forecast/skill?station_code=P.1&horizon=6").await;
|
||||||
|
let versions = sk["versions"].as_array().unwrap();
|
||||||
|
assert_eq!(versions.len(), 2);
|
||||||
|
assert_eq!(versions[0]["model_version"], "v1");
|
||||||
|
assert_eq!(versions[0]["n"], 24);
|
||||||
|
assert_eq!(versions[0]["enough_data"], true);
|
||||||
|
assert_eq!(sk["current"]["model_version"], "v2");
|
||||||
|
assert_eq!(sk["trend"]["previous_version"], "v1");
|
||||||
|
|
||||||
|
let (_, _, health) = get_json(&app, "/health").await;
|
||||||
|
assert_eq!(health["checks"]["database"]["status"], "degraded"); // data from 2026-10-03
|
||||||
|
assert!(
|
||||||
|
health["checks"]["database"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("Latest data is old: 2026-10-03 23:00:00")
|
||||||
|
);
|
||||||
|
db.drop().await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare the Rust website/API with the FastAPI app, route by route.
|
||||||
|
|
||||||
|
Phase 3 of docs/RUST_REWRITE.md: before the Rust service takes over port
|
||||||
|
8000, both answer the same requests against the same database and every
|
||||||
|
response is compared: status, content type, Cache-Control and the JSON body
|
||||||
|
(floats exactly; lists of database rows without ORDER BY as sets). Static
|
||||||
|
files and the dashboard byte for byte.
|
||||||
|
|
||||||
|
# the FastAPI app on :8000, `ping-collector serve --listen 127.0.0.1:8001`
|
||||||
|
uv run python scripts/api_parity.py \\
|
||||||
|
--python http://127.0.0.1:8000 --rust http://127.0.0.1:8001
|
||||||
|
|
||||||
|
Query parameters vary per run (limits, hours) so the FastAPI app's response
|
||||||
|
caches (up to 15 min) answer with fresh data, not an older copy. Data the
|
||||||
|
collector writes during the run can still make a route differ once: a
|
||||||
|
mismatch is retried after a pause before it counts.
|
||||||
|
|
||||||
|
Intended differences (the Rust side may answer differently):
|
||||||
|
/health a lagging RID report with current ThaiWater telemetry for
|
||||||
|
the same gauges is healthy (as the notifier, phase 2)
|
||||||
|
/scraping/status extra fields (collector version, forecast run)
|
||||||
|
/metrics process counters: compared by shape only
|
||||||
|
/sensors/thaiwater live upstream: compared, a difference only warns
|
||||||
|
/api/hii/rainfall/catchment correlation within 1e-9 (numpy uses BLAS)
|
||||||
|
/api/stats newer counts on the Rust side are accepted (the FastAPI app
|
||||||
|
caches this one for 5 minutes)
|
||||||
|
*.geojson content type application/geo+json (FastAPI: octet-stream)
|
||||||
|
|
||||||
|
Exit status 0 when every hard check passed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, List, Optional, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
SOFT = "soft" # a difference warns, does not fail
|
||||||
|
|
||||||
|
|
||||||
|
def get(base: str, path: str, method: str = "GET", **kw) -> requests.Response:
|
||||||
|
last = None
|
||||||
|
for _ in range(3):
|
||||||
|
try:
|
||||||
|
return requests.request(method, base + path, timeout=120, **kw)
|
||||||
|
except requests.RequestException as e: # connection reset etc.
|
||||||
|
last = e
|
||||||
|
time.sleep(1)
|
||||||
|
raise last
|
||||||
|
|
||||||
|
|
||||||
|
def first_diff(a: Any, b: Any, path: str = "$", rel: float = 0.0) -> Optional[str]:
|
||||||
|
"""Where two decoded JSON values differ (None if equal)."""
|
||||||
|
if isinstance(a, bool) or isinstance(b, bool):
|
||||||
|
return None if a is b else f"{path}: {a!r} != {b!r}"
|
||||||
|
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
||||||
|
if a == b:
|
||||||
|
return None
|
||||||
|
if rel and abs(a - b) <= rel * max(abs(a), abs(b)):
|
||||||
|
return None
|
||||||
|
return f"{path}: {a!r} != {b!r}"
|
||||||
|
if type(a) is not type(b):
|
||||||
|
return f"{path}: {type(a).__name__} {a!r} != {type(b).__name__} {b!r}"
|
||||||
|
if isinstance(a, dict):
|
||||||
|
if list(a.keys()) != list(b.keys()):
|
||||||
|
if set(a.keys()) != set(b.keys()):
|
||||||
|
return f"{path}: keys {sorted(set(a) ^ set(b))} only on one side"
|
||||||
|
for k in a:
|
||||||
|
d = first_diff(a[k], b[k], f"{path}.{k}", rel)
|
||||||
|
if d:
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
if isinstance(a, list):
|
||||||
|
if len(a) != len(b):
|
||||||
|
return f"{path}: {len(a)} items != {len(b)} items"
|
||||||
|
for i, (x, y) in enumerate(zip(a, b)):
|
||||||
|
d = first_diff(x, y, f"{path}[{i}]", rel)
|
||||||
|
if d:
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
return None if a == b else f"{path}: {a!r} != {b!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def media_type(r: requests.Response) -> str:
|
||||||
|
return r.headers.get("content-type", "").split(";")[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
class Case:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
method: str = "GET",
|
||||||
|
compare: Optional[Callable] = None,
|
||||||
|
sort_key: Optional[str] = None,
|
||||||
|
soft: bool = False,
|
||||||
|
body: Any = None,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
):
|
||||||
|
self.path, self.method, self.compare = path, method, compare
|
||||||
|
self.sort_key, self.soft, self.body = sort_key, soft, body
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
def run(self, py: str, rs: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""(hard difference, warning)"""
|
||||||
|
kw = {"headers": self.headers}
|
||||||
|
if self.body is not None:
|
||||||
|
kw["json"] = self.body
|
||||||
|
a = get(py, self.path, self.method, **kw)
|
||||||
|
b = get(rs, self.path, self.method, **kw)
|
||||||
|
if a.status_code != b.status_code:
|
||||||
|
return self._fail(
|
||||||
|
f"status {a.status_code} != {b.status_code} "
|
||||||
|
f"({a.text[:120]!r} / {b.text[:120]!r})"
|
||||||
|
)
|
||||||
|
if media_type(a) != media_type(b):
|
||||||
|
warn = f"content-type {media_type(a)!r} != {media_type(b)!r}"
|
||||||
|
if media_type(a).startswith("application/json") or media_type(b).startswith(
|
||||||
|
"application/json"
|
||||||
|
):
|
||||||
|
return self._fail(warn)
|
||||||
|
return None, warn
|
||||||
|
if self.method == "GET" and a.status_code == 200:
|
||||||
|
ca, cb = a.headers.get("cache-control"), b.headers.get("cache-control")
|
||||||
|
if ca != cb:
|
||||||
|
return self._fail(f"Cache-Control {ca!r} != {cb!r}")
|
||||||
|
if self.compare is not None:
|
||||||
|
d = self.compare(a, b)
|
||||||
|
elif media_type(a) == "application/json":
|
||||||
|
ja, jb = a.json(), b.json()
|
||||||
|
if a.status_code == 422:
|
||||||
|
# pydantic's messages are approximated; type and location must agree
|
||||||
|
ja = [(e["type"], e["loc"]) for e in ja["detail"]]
|
||||||
|
jb = [(e["type"], e["loc"]) for e in jb["detail"]]
|
||||||
|
if any(t.startswith("date") for t, _ in ja):
|
||||||
|
ja, jb = [loc for _, loc in ja], [loc for _, loc in jb]
|
||||||
|
elif self.sort_key and isinstance(ja, list):
|
||||||
|
ja = sorted(ja, key=lambda r: str(r.get(self.sort_key)))
|
||||||
|
jb = sorted(jb, key=lambda r: str(r.get(self.sort_key)))
|
||||||
|
d = first_diff(ja, jb)
|
||||||
|
else:
|
||||||
|
d = (
|
||||||
|
None
|
||||||
|
if a.content == b.content
|
||||||
|
else f"bodies differ ({len(a.content)} / {len(b.content)} bytes)"
|
||||||
|
)
|
||||||
|
return self._fail(d) if d else (None, None)
|
||||||
|
|
||||||
|
def _fail(self, d: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
return (None, d) if self.soft else (d, None)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_health(a, b) -> Optional[str]:
|
||||||
|
ja, jb = a.json(), b.json()
|
||||||
|
if set(ja["checks"]) != set(jb["checks"]):
|
||||||
|
return f"checks {sorted(ja['checks'])} != {sorted(jb['checks'])}"
|
||||||
|
for name, ca in ja["checks"].items():
|
||||||
|
cb = jb["checks"][name]
|
||||||
|
if ca["status"] == cb["status"]:
|
||||||
|
continue
|
||||||
|
# the intended change: RID lagging, ThaiWater current
|
||||||
|
if (
|
||||||
|
name == "database"
|
||||||
|
and ca["status"] == "degraded"
|
||||||
|
and cb["status"] == "healthy"
|
||||||
|
and "ThaiWater telemetry current" in cb["message"]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if name in ("api", "memory"):
|
||||||
|
continue # probes of the RID API / own process, compared loosely
|
||||||
|
return f"{name}: {ca} != {cb}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare_metrics(a, b) -> Optional[str]:
|
||||||
|
ja, jb = a.json(), b.json()
|
||||||
|
return None if set(ja) == set(jb) else f"keys {sorted(ja)} != {sorted(jb)}"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_status(a, b) -> Optional[str]:
|
||||||
|
ja, jb = a.json(), b.json()
|
||||||
|
for k, v in ja.items():
|
||||||
|
if jb.get(k) != v:
|
||||||
|
return f"{k}: {v!r} != {jb.get(k)!r}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare_skill(a, b) -> Optional[str]:
|
||||||
|
ja, jb = a.json(), b.json()
|
||||||
|
va, vb = ja.pop("verified_until"), jb.pop("verified_until")
|
||||||
|
gap = abs(
|
||||||
|
time.mktime(time.strptime(va[:19], "%Y-%m-%dT%H:%M:%S"))
|
||||||
|
- time.mktime(time.strptime(vb[:19], "%Y-%m-%dT%H:%M:%S"))
|
||||||
|
)
|
||||||
|
if gap > 300:
|
||||||
|
return f"verified_until {va} != {vb}"
|
||||||
|
return first_diff(ja, jb)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_stats(a, b) -> Optional[str]:
|
||||||
|
"""Exact, except that the FastAPI app may answer from its 5-minute cache
|
||||||
|
while the collector has written newer rows since."""
|
||||||
|
ja, jb = a.json(), b.json()
|
||||||
|
if ja == jb:
|
||||||
|
return None
|
||||||
|
for k, v in ja.items():
|
||||||
|
w = jb.get(k)
|
||||||
|
if k.endswith("_measurements") or k == "days_spanned":
|
||||||
|
if not isinstance(w, int) or w < v:
|
||||||
|
return f"{k}: {v!r} > {w!r}"
|
||||||
|
elif k == "last_timestamp":
|
||||||
|
if not isinstance(w, str) or w < v:
|
||||||
|
return f"{k}: {v!r} > {w!r}"
|
||||||
|
elif k == "coverage_percent":
|
||||||
|
if not isinstance(w, (int, float)) or abs(w - v) > 0.2:
|
||||||
|
return f"{k}: {v!r} != {w!r}"
|
||||||
|
elif v != w:
|
||||||
|
return f"{k}: {v!r} != {w!r}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare_catchment(a, b) -> Optional[str]:
|
||||||
|
return first_diff(a.json(), b.json(), rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_openapi(a, b) -> Optional[str]:
|
||||||
|
ja, jb = a.json(), b.json()
|
||||||
|
if ja["info"]["title"] != jb["info"]["title"]:
|
||||||
|
return "title differs"
|
||||||
|
ops = lambda spec: { # noqa: E731
|
||||||
|
(m, p): op for p, item in spec["paths"].items() for m, op in item.items()
|
||||||
|
}
|
||||||
|
oa, ob = ops(ja), ops(jb)
|
||||||
|
if set(oa) != set(ob):
|
||||||
|
return f"operations only on one side: {sorted(set(oa) ^ set(ob))}"
|
||||||
|
for k, op in oa.items():
|
||||||
|
other = ob[k]
|
||||||
|
for field in ("operationId", "summary", "parameters", "requestBody"):
|
||||||
|
d = first_diff(op.get(field), other.get(field), f"{k}.{field}")
|
||||||
|
if d:
|
||||||
|
return d
|
||||||
|
if set(op["responses"]) != set(other["responses"]):
|
||||||
|
return f"{k}: responses {sorted(op['responses'])} != {sorted(other['responses'])}"
|
||||||
|
for name in ja["components"]["schemas"]:
|
||||||
|
if name not in jb["components"]["schemas"]:
|
||||||
|
return f"schema {name} missing"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare_html_docs(a, b) -> Optional[str]:
|
||||||
|
return (
|
||||||
|
None
|
||||||
|
if "swagger-ui" in b.text and "/openapi.json" in b.text
|
||||||
|
else "no Swagger UI"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cases(admin_key: Optional[str]) -> List[Case]:
|
||||||
|
r = random.Random(time.time())
|
||||||
|
hours = r.randint(150, 190) # fresh keys for the Python app's caches
|
||||||
|
limit = r.randint(40000, 49999)
|
||||||
|
feed_hours = r.randint(20, 30)
|
||||||
|
skill_station = r.choice(
|
||||||
|
["P.67", "P.103", "P.20", "P.75", "P.92", "P.21", "P.5", "P.82"]
|
||||||
|
)
|
||||||
|
day = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400 * 3))
|
||||||
|
day2 = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400))
|
||||||
|
out = [
|
||||||
|
Case("/"),
|
||||||
|
Case("/robots.txt"),
|
||||||
|
Case("/llms.txt"),
|
||||||
|
Case("/sitemap.xml"),
|
||||||
|
Case("/static/ping-river-network.geojson", soft=True),
|
||||||
|
Case("/static/custom-markers.json"),
|
||||||
|
Case("/static/flood-zones-p1.geojson", soft=True),
|
||||||
|
Case("/static/flood-2024-all.json"),
|
||||||
|
Case("/static/nope.json"),
|
||||||
|
Case("/openapi.json", compare=compare_openapi),
|
||||||
|
Case("/docs", compare=compare_html_docs),
|
||||||
|
Case("/health", compare=compare_health),
|
||||||
|
Case("/metrics", compare=compare_metrics),
|
||||||
|
Case("/stations"),
|
||||||
|
Case("/stations/8"),
|
||||||
|
Case("/stations/1"),
|
||||||
|
Case("/stations/999"),
|
||||||
|
Case("/stations/abc"),
|
||||||
|
Case(f"/measurements/latest?limit={r.randint(200, 499)}"),
|
||||||
|
Case(f"/measurements/latest?limit={r.randint(1, 15)}"),
|
||||||
|
Case(f"/measurements/history/P.1?hours={hours}&limit={limit}"),
|
||||||
|
Case(f"/measurements/history/P.67?hours={hours + 1}"),
|
||||||
|
Case(f"/measurements/history/P.1?start={day}&end={day2}&limit={limit - 1}"),
|
||||||
|
Case(f"/measurements/history/P.1?start={day}&limit={r.randint(5, 50)}"),
|
||||||
|
Case(f"/measurements/history/P.1?start={day2}&end={day}"),
|
||||||
|
Case(f"/measurements/history/NOPE?hours={hours}"),
|
||||||
|
Case("/measurements/history/P.1?hours=0"),
|
||||||
|
Case("/measurements/history/P.1?limit=100001"),
|
||||||
|
Case("/measurements/history/P.1?start=yesterday"),
|
||||||
|
Case(f"/measurements/station/P.1?hours={r.randint(20, 48)}"),
|
||||||
|
Case("/measurements/station/P.103?hours=6&limit=2"),
|
||||||
|
Case(f"/api/hii/rainfall/latest?hours={feed_hours}", sort_key="station_id"),
|
||||||
|
Case(
|
||||||
|
f"/api/hii/waterlevel/latest?hours={feed_hours + 1}", sort_key="station_id"
|
||||||
|
),
|
||||||
|
Case("/api/hii/waterlevel/latest?hours=500"),
|
||||||
|
Case(
|
||||||
|
f"/api/hii/rainfall/catchment?days={r.randint(15, 30)}",
|
||||||
|
compare=compare_catchment,
|
||||||
|
),
|
||||||
|
Case("/sensors/thaiwater", sort_key="id", soft=True),
|
||||||
|
Case("/forecast"),
|
||||||
|
Case(f"/api/forecast/history/P.1?hours={hours}&horizon=24"),
|
||||||
|
Case(f"/api/forecast/history/P.103?hours={feed_hours}"),
|
||||||
|
Case(f"/api/forecast/history/P.1?start={day}&end={day2}&horizon=6"),
|
||||||
|
Case("/api/forecast/history/P.1?horizon=99"),
|
||||||
|
Case("/api/notifications"),
|
||||||
|
# a station/horizon pair the dashboard does not ask for: not in the
|
||||||
|
# FastAPI app's 15-minute cache; the dashboard's own one may be
|
||||||
|
Case(
|
||||||
|
f"/api/forecast/skill?station_code={skill_station}"
|
||||||
|
f"&horizon={r.randint(1, 48)}",
|
||||||
|
compare=compare_skill,
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
"/api/forecast/skill?station_code=P.1&horizon=24",
|
||||||
|
compare=compare_skill,
|
||||||
|
soft=True,
|
||||||
|
),
|
||||||
|
Case("/api/forecast/skill?horizon=0"),
|
||||||
|
Case("/api/stats", compare=compare_stats),
|
||||||
|
Case("/scraping/status", compare=compare_status),
|
||||||
|
Case("/nope"),
|
||||||
|
Case("/forecast", method="DELETE"),
|
||||||
|
Case("/config"),
|
||||||
|
Case("/scrape/trigger", method="POST"),
|
||||||
|
Case("/stations", method="POST", body={"station_code": "X"}),
|
||||||
|
Case("/stations/8", method="DELETE"),
|
||||||
|
]
|
||||||
|
if admin_key:
|
||||||
|
key = {"X-API-Key": admin_key}
|
||||||
|
out += [
|
||||||
|
Case("/config", headers={"X-API-Key": "wrong"}),
|
||||||
|
Case("/stations", method="POST", body={"thai_name": 1}, headers=key),
|
||||||
|
Case("/stations/999", method="PUT", body={}, headers=key),
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||||
|
ap.add_argument("--python", default="http://127.0.0.1:8000")
|
||||||
|
ap.add_argument("--rust", default="http://127.0.0.1:8001")
|
||||||
|
ap.add_argument(
|
||||||
|
"--admin-key", help="ADMIN_API_KEY, to compare the admin errors too"
|
||||||
|
)
|
||||||
|
ap.add_argument("--retries", type=int, default=2)
|
||||||
|
ap.add_argument("--pause", type=float, default=30.0, help="seconds before a retry")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
failed, warned, passed = [], [], 0
|
||||||
|
for case in cases(args.admin_key):
|
||||||
|
label = f"{case.method} {case.path}"
|
||||||
|
for attempt in range(args.retries + 1):
|
||||||
|
try:
|
||||||
|
hard, warn = case.run(args.python, args.rust)
|
||||||
|
except Exception as e: # noqa: BLE001 - report and go on
|
||||||
|
hard, warn = f"{type(e).__name__}: {e}", None
|
||||||
|
if not hard or attempt == args.retries:
|
||||||
|
break
|
||||||
|
print(f" retry {label}: {hard}", flush=True)
|
||||||
|
time.sleep(args.pause)
|
||||||
|
if hard:
|
||||||
|
failed.append((label, hard))
|
||||||
|
print(f"FAIL {label}: {hard}", flush=True)
|
||||||
|
elif warn:
|
||||||
|
warned.append((label, warn))
|
||||||
|
print(f"warn {label}: {warn}", flush=True)
|
||||||
|
else:
|
||||||
|
passed += 1
|
||||||
|
print(
|
||||||
|
f"{passed} identical, {len(warned)} warnings, {len(failed)} different "
|
||||||
|
f"({args.python} vs {args.rust})"
|
||||||
|
)
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,18 +1,29 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Install the Rust collector (rust/collector, `ping-collector`) and move work
|
# Install the Rust service (rust/collector, `ping-collector`) and move work
|
||||||
# from the Python app to it, or back.
|
# from the Python app to it, or back.
|
||||||
#
|
#
|
||||||
# sudo bash scripts/install_collector.sh # install binary + unit; checks only
|
# sudo bash scripts/install_collector.sh # install binary + unit; checks only
|
||||||
# sudo bash scripts/install_collector.sh --cutover # Rust collects (COLLECTION_ENABLED=false)
|
# sudo bash scripts/install_collector.sh --cutover # Rust collects (COLLECTION_ENABLED=false)
|
||||||
# sudo bash scripts/install_collector.sh --cutover-forecast # Rust also forecasts and sends the
|
# sudo bash scripts/install_collector.sh --cutover-forecast # Rust also forecasts and notifies:
|
||||||
# # ntfy notifications (FORECAST_IN_COLLECTOR=true)
|
# # ntfy + Matrix (FORECAST_IN_COLLECTOR=true)
|
||||||
|
# sudo bash scripts/install_collector.sh --check-api # compare the Rust website/API with the
|
||||||
|
# # Python app's, change nothing
|
||||||
|
# sudo bash scripts/install_collector.sh --cutover-api # Rust serves the website and API on
|
||||||
|
# # port 8000 (WEB_LISTEN); the Python app stops
|
||||||
|
# sudo bash scripts/install_collector.sh --rollback-api # the Python app serves the website again
|
||||||
# sudo bash scripts/install_collector.sh --rollback-forecast # forecasts + notifications back to Python
|
# sudo bash scripts/install_collector.sh --rollback-forecast # forecasts + notifications back to Python
|
||||||
# sudo bash scripts/install_collector.sh --rollback # Python does everything again
|
# sudo bash scripts/install_collector.sh --rollback # Python does everything again
|
||||||
#
|
#
|
||||||
# Every install also exports the trained models for the Rust forecaster and
|
# Every install also exports the trained models for the Rust forecaster and
|
||||||
# compares its forecast with Python's on the live data
|
# compares its forecast with Python's on the live data
|
||||||
# (scripts/forecast_parity.py); --cutover-forecast refuses to switch when they
|
# (scripts/forecast_parity.py); --cutover-forecast refuses to switch when they
|
||||||
# differ (FORCE=1 overrides).
|
# differ. --cutover-api first runs `ping-collector serve` next to the Python
|
||||||
|
# app and compares every route (scripts/api_parity.py); it refuses to switch
|
||||||
|
# when they differ. FORCE=1 overrides either check.
|
||||||
|
#
|
||||||
|
# Once ping-collector sends the Matrix messages (FORECAST_IN_COLLECTOR=true
|
||||||
|
# and MATRIX_* set), the old `run.py --alert-check` cron job is commented out
|
||||||
|
# (backup next to the app); it repeated an alert on every run.
|
||||||
#
|
#
|
||||||
# The binary comes from BIN=/path/to/ping-collector (e.g. the "ping-collector-
|
# The binary comes from BIN=/path/to/ping-collector (e.g. the "ping-collector-
|
||||||
# linux-x86_64" CI artifact) or from rust/target/release/ping-collector, built
|
# linux-x86_64" CI artifact) or from rust/target/release/ping-collector, built
|
||||||
@@ -28,6 +39,8 @@ ENV_FILE="${APP_DIR}/.env"
|
|||||||
STATUS_FILE="${APP_DIR}/collector-status.json"
|
STATUS_FILE="${APP_DIR}/collector-status.json"
|
||||||
PYTHON="${APP_DIR}/.venv/bin/python"
|
PYTHON="${APP_DIR}/.venv/bin/python"
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WEB_ADDR="${WEB_ADDR:-0.0.0.0:8000}" # where the Python app listens today
|
||||||
|
CHECK_PORT="${CHECK_PORT:-8001}" # the Rust side during --check-api
|
||||||
MODE="${1:-install}"
|
MODE="${1:-install}"
|
||||||
|
|
||||||
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
@@ -64,24 +77,116 @@ show() {
|
|||||||
journalctl -u ping-collector -n 25 --no-pager || true
|
journalctl -u ping-collector -n 25 --no-pager || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
env_is() { grep -q "^$1=$2\s*$" "$ENV_FILE"; }
|
||||||
|
|
||||||
|
wait_http() { # url seconds
|
||||||
|
local i
|
||||||
|
for i in $(seq "$2"); do
|
||||||
|
curl -fsS -o /dev/null --max-time 5 "$1" && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Matrix messages come from ping-collector once it notifies; the old
|
||||||
|
# cron job (`run.py --alert-check`, every 15 min, repeating while a level
|
||||||
|
# stays high) is commented out, with a backup of the crontab.
|
||||||
|
retire_matrix_cron() {
|
||||||
|
env_is FORECAST_IN_COLLECTOR true || return 0
|
||||||
|
grep -q '^MATRIX_ACCESS_TOKEN=..*' "$ENV_FILE" || return 0
|
||||||
|
local user backup
|
||||||
|
for user in root "$SERVICE_USER"; do
|
||||||
|
crontab -l -u "$user" 2>/dev/null | grep -v '^\s*#' | grep -q -- '--alert-check' || continue
|
||||||
|
backup="${APP_DIR}/crontab-${user}.$(date +%Y%m%d%H%M%S).bak"
|
||||||
|
crontab -l -u "$user" >"$backup"
|
||||||
|
sed '/--alert-check/ s|^\([^#]\)|# retired: Matrix is sent by ping-collector # \1|' "$backup" |
|
||||||
|
crontab -u "$user" -
|
||||||
|
log "Matrix: commented out the old --alert-check cron job of $user (backup: $backup)"
|
||||||
|
done
|
||||||
|
if grep -rlqs -- '--alert-check' /etc/cron.d /etc/crontab 2>/dev/null; then
|
||||||
|
warn "an --alert-check job is still in $(grep -rls -- '--alert-check' /etc/cron.d /etc/crontab | tr '\n' ' '): remove it, ping-collector sends Matrix now"
|
||||||
|
fi
|
||||||
|
if systemctl list-timers --all --no-pager 2>/dev/null | grep -qi alert; then
|
||||||
|
warn "a systemd timer mentions 'alert' (systemctl list-timers --all): if it runs --alert-check, disable it"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ping-collector serve next to the Python app; every route compared.
|
||||||
|
api_check() {
|
||||||
|
local unit=ping-api-check ok=0
|
||||||
|
systemctl stop "$unit" 2>/dev/null || true
|
||||||
|
systemd-run --quiet --unit="$unit" --collect \
|
||||||
|
-p User="$SERVICE_USER" -p Group="$(id -gn "$SERVICE_USER")" \
|
||||||
|
-p WorkingDirectory="$APP_DIR" -p EnvironmentFile="$ENV_FILE" \
|
||||||
|
"${APP_DIR}/bin/ping-collector" serve --listen "127.0.0.1:${CHECK_PORT}" --no-analytics
|
||||||
|
if wait_http "http://127.0.0.1:${CHECK_PORT}/api/notifications" 30; then
|
||||||
|
as_service "$PYTHON" scripts/api_parity.py \
|
||||||
|
--python "http://127.0.0.1:${WEB_ADDR##*:}" --rust "http://127.0.0.1:${CHECK_PORT}" && ok=1
|
||||||
|
else
|
||||||
|
warn "ping-collector serve did not answer on port ${CHECK_PORT}:"
|
||||||
|
journalctl -u "$unit" -n 20 --no-pager || true
|
||||||
|
fi
|
||||||
|
systemctl stop "$unit" 2>/dev/null || true
|
||||||
|
[ "$ok" = 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_api() {
|
||||||
|
if env_is WEB_LISTEN '..*'; then
|
||||||
|
log "The Python app serves the website and API again"
|
||||||
|
set_env WEB_LISTEN ""
|
||||||
|
systemctl restart ping-collector.service
|
||||||
|
fi
|
||||||
|
systemctl enable water-monitor.service
|
||||||
|
systemctl restart water-monitor.service
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix_backups() {
|
||||||
|
ls "${APP_DIR}"/crontab-*.bak 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
}
|
||||||
|
|
||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
--rollback)
|
--rollback)
|
||||||
log "Stopping ping-collector; the Python app collects, forecasts and notifies again"
|
log "Stopping ping-collector; the Python app collects, serves, forecasts and notifies again"
|
||||||
systemctl disable --now ping-collector.service 2>/dev/null || true
|
systemctl disable --now ping-collector.service 2>/dev/null || true
|
||||||
|
set_env WEB_LISTEN ""
|
||||||
set_env FORECAST_IN_COLLECTOR false
|
set_env FORECAST_IN_COLLECTOR false
|
||||||
set_env COLLECTION_ENABLED true
|
set_env COLLECTION_ENABLED true
|
||||||
|
systemctl enable water-monitor.service
|
||||||
systemctl restart water-monitor.service
|
systemctl restart water-monitor.service
|
||||||
|
if [ -n "$(matrix_backups)" ]; then
|
||||||
|
warn "Matrix alerts came from ping-collector; to restore the old cron job: crontab -u USER BACKUP"
|
||||||
|
matrix_backups
|
||||||
|
fi
|
||||||
log "Done. Check: journalctl -u water-monitor -n 50"
|
log "Done. Check: journalctl -u water-monitor -n 50"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
|
--rollback-api)
|
||||||
|
rollback_api
|
||||||
|
log "Done. Check: journalctl -u water-monitor -n 50; curl -s http://127.0.0.1:${WEB_ADDR##*:}/health"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
--rollback-forecast)
|
--rollback-forecast)
|
||||||
|
# the Python app forecasts in its own process: it must serve again
|
||||||
|
rollback_api
|
||||||
log "Forecasts and notifications back to the Python app; ping-collector keeps collecting"
|
log "Forecasts and notifications back to the Python app; ping-collector keeps collecting"
|
||||||
set_env FORECAST_IN_COLLECTOR false
|
set_env FORECAST_IN_COLLECTOR false
|
||||||
systemctl restart ping-collector.service water-monitor.service
|
systemctl restart ping-collector.service water-monitor.service
|
||||||
|
if [ -n "$(matrix_backups)" ]; then
|
||||||
|
warn "Matrix alerts stop with this (the Python app sends ntfy only); old cron job: crontab -u USER BACKUP"
|
||||||
|
matrix_backups
|
||||||
|
fi
|
||||||
log "Done. Check: journalctl -u water-monitor -n 50"
|
log "Done. Check: journalctl -u water-monitor -n 50"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
install | --cutover | --cutover-forecast) ;;
|
--check-api)
|
||||||
|
[ -x "${APP_DIR}/bin/ping-collector" ] || die "install first"
|
||||||
|
if env_is WEB_LISTEN '..*'; then
|
||||||
|
die "ping-collector already serves the website (WEB_LISTEN is set)"
|
||||||
|
fi
|
||||||
|
api_check && log "The Rust API answers as the Python app" || die "the API check found differences"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
install | --cutover | --cutover-forecast | --cutover-api) ;;
|
||||||
*) die "unknown option: $MODE" ;;
|
*) die "unknown option: $MODE" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -130,12 +235,47 @@ case "$MODE" in
|
|||||||
fi
|
fi
|
||||||
log "Cutover: ping-collector also forecasts and notifies; the Python app serves its forecast"
|
log "Cutover: ping-collector also forecasts and notifies; the Python app serves its forecast"
|
||||||
set_env FORECAST_IN_COLLECTOR true
|
set_env FORECAST_IN_COLLECTOR true
|
||||||
|
retire_matrix_cron
|
||||||
systemctl enable ping-collector.service
|
systemctl enable ping-collector.service
|
||||||
systemctl restart ping-collector.service water-monitor.service
|
systemctl restart ping-collector.service water-monitor.service
|
||||||
show 30
|
show 30
|
||||||
log "Rollback if needed: sudo bash scripts/install_collector.sh --rollback-forecast"
|
log "Next step: --cutover-api. Rollback: sudo bash scripts/install_collector.sh --rollback-forecast"
|
||||||
|
;;
|
||||||
|
--cutover-api)
|
||||||
|
env_is FORECAST_IN_COLLECTOR true || die "ping-collector must forecast first: run --cutover-forecast"
|
||||||
|
if env_is WEB_LISTEN '..*'; then
|
||||||
|
log "ping-collector already serves the website; restarting it on the new binary"
|
||||||
|
retire_matrix_cron
|
||||||
|
systemctl restart ping-collector.service
|
||||||
|
show 10
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
api_ok=1
|
||||||
|
if [ "$(systemctl is-active water-monitor.service 2>/dev/null)" = active ]; then
|
||||||
|
log "API check: ping-collector serve on :${CHECK_PORT} next to the Python app, every route compared"
|
||||||
|
api_check || api_ok=0
|
||||||
|
else
|
||||||
|
warn "the Python app is not running: nothing to compare the API with"
|
||||||
|
api_ok=0
|
||||||
|
fi
|
||||||
|
if [ "$api_ok" != 1 ] && [ "${FORCE:-0}" != 1 ]; then
|
||||||
|
die "the API check did not pass; not switching (FORCE=1 overrides)"
|
||||||
|
fi
|
||||||
|
log "Cutover: ping-collector serves the website and API on ${WEB_ADDR}; the Python app stops"
|
||||||
|
set_env WEB_LISTEN "$WEB_ADDR"
|
||||||
|
retire_matrix_cron
|
||||||
|
systemctl disable --now water-monitor.service
|
||||||
|
systemctl restart ping-collector.service
|
||||||
|
if wait_http "http://127.0.0.1:${WEB_ADDR##*:}/health" 60; then
|
||||||
|
log "The website answers from ping-collector: $(curl -s --max-time 5 "http://127.0.0.1:${WEB_ADDR##*:}/scraping/status" | grep -o '"collector":"[^"]*"')"
|
||||||
|
else
|
||||||
|
warn "no answer on port ${WEB_ADDR##*:} yet; check journalctl -u ping-collector"
|
||||||
|
fi
|
||||||
|
show 5
|
||||||
|
log "Rollback if needed: sudo bash scripts/install_collector.sh --rollback-api"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
retire_matrix_cron
|
||||||
if [ "$(systemctl is-active ping-collector.service 2>/dev/null)" = active ]; then
|
if [ "$(systemctl is-active ping-collector.service 2>/dev/null)" = active ]; then
|
||||||
systemctl restart ping-collector.service
|
systemctl restart ping-collector.service
|
||||||
log "Installed; ping-collector restarted on the new binary"
|
log "Installed; ping-collector restarted on the new binary"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Ping River Monitor collector (Rust: RID, HII, dams, Open-Meteo; forecast + ntfy)
|
Description=Ping River Monitor (Rust: collection, forecast, ntfy/Matrix, website/API)
|
||||||
Documentation=https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/src/branch/master/rust
|
Documentation=https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/src/branch/master/rust
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
@@ -27,7 +27,8 @@ ProtectSystem=strict
|
|||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/opt/thailand-water-monitor
|
ReadWritePaths=/opt/thailand-water-monitor
|
||||||
CapabilityBoundingSet=
|
CapabilityBoundingSet=
|
||||||
MemoryMax=256M
|
# collection + 15 models ~100 MB, response caches up to ~100 MB more
|
||||||
|
MemoryMax=512M
|
||||||
|
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
|
|||||||
Reference in new issue
Block a user