feat(web): the website and API in ping-collector; Matrix gets the ntfy transitions
CI / Format & lint (push) Successful in 26s
CI / Test suite (push) Successful in 1m9s
Docs / Validate documentation (push) Successful in 25s
Rust / fmt, clippy, test, build (push) Successful in 11m46s

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:
grabowski committed 2026-10-18 16:08:06 +07:00
1 parent 1d1c000922
commit 779728e38b
32 files changed
+5692 -160

No files matched your search

+241 -15
View File
@@ -1,5 +1,8 @@
//! Public flood notifications over ntfy: the port of src/notify.py, run by
//! the collector when FORECAST_IN_COLLECTOR=true.
//! Public flood notifications over ntfy, and the same transitions to a
//! 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
//! 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
//! is reported separately after RID_STALE_ALERT_HOURS.
use crate::config::MatrixConfig;
use crate::db::{self, LatestReading};
use anyhow::Result;
use chrono::{Duration, NaiveDateTime};
@@ -425,22 +429,105 @@ pub fn evaluate(
plan
}
/// Posts to ntfy and records delivered transitions.
pub struct Notifier {
pub http: reqwest::Client,
/// ntfy publishing target.
pub struct Ntfy {
pub publish_url: 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 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
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 {
pub async fn publish(&self, n: &Notification) -> bool {
pub async fn publish(&self, ntfy: &Ntfy, n: &Notification) -> bool {
let mut req = self
.http
.post(format!(
"{}/{}",
self.publish_url.trim_end_matches('/'),
ntfy.publish_url.trim_end_matches('/'),
n.topic
))
.timeout(std::time::Duration::from_secs(10))
@@ -455,7 +542,7 @@ impl Notifier {
if !n.tags.is_empty() {
req = req.header("Tags", n.tags.join(","));
}
if let Some(t) = &self.token {
if let Some(t) = &ntfy.token {
req = req.bearer_auth(t);
}
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 &copy {
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(
&self,
pool: &PgPool,
@@ -487,14 +673,32 @@ impl Notifier {
) -> Result<Vec<Notification>> {
let states = db::notification_states(pool).await?;
let mut sent = vec![];
for p in evaluate(readings, forecasts, &states, &self.settings, now) {
if !self.publish(&p.notification).await {
continue;
if let Some(ntfy) = &self.ntfy {
for p in evaluate(readings, forecasts, &states, &self.settings, now) {
if !self.publish(ntfy, &p.notification).await {
continue;
}
if let Some((key, state, value)) = &p.state {
db::set_notification_state(pool, key, state, *value, now).await?;
}
sent.push(p.notification);
}
if let Some((key, state, value)) = &p.state {
db::set_notification_state(pool, key, state, *value, now).await?;
}
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
});
}
sent.push(p.notification);
}
Ok(sent)
}
@@ -505,6 +709,28 @@ mod tests {
use super::*;
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 &lt;x&gt; &amp; 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 {
NaiveDate::from_ymd_opt(2026, 10, 1)
.unwrap()