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
+5692
-160
No files matched your search
+241
-15
@@ -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('&', "&")
|
||||
.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 {
|
||||
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 © {
|
||||
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 <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 {
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1)
|
||||
.unwrap()
|
||||
|
||||
Reference in new issue
Block a user