添加市场监控 Rust 网页版:Axum+Askama+HTMX,直读 SQLite

- 11 城市卡片网格,暗色主题,响应式布局
- 60s HTMX 轮询局部刷新
- 当前温度、今日最高、趋势箭头(线性回归)
- 首尔/釜山跑道温度(预留)
- 零 Python 依赖,编译后 ~3MB 二进制

Constraint: 读 POLYWEATHER_DB_PATH 指向的 SQLite
@
This commit is contained in:
2569718930@qq.com
2026-05-13 19:24:24 +08:00
parent 86467d4e92
commit db9071101a
9 changed files with 1731 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct ObsRow {
pub temp_c: Option<f64>,
#[allow(dead_code)]
pub obs_time: Option<String>,
}
/// Get recent temperature observations for an ICAO station.
pub fn get_recent_obs(db_path: &str, icao: &str, minutes: i32, limit: usize) -> Vec<ObsRow> {
let conn = match Connection::open(db_path) {
Ok(c) => c,
Err(_) => return vec![],
};
let sql = format!(
"SELECT temp_c, obs_time FROM airport_obs_log \
WHERE icao = ?1 AND created_at > datetime('now', '{} minutes') \
ORDER BY created_at DESC LIMIT ?2",
-minutes
);
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return vec![],
};
let rows = stmt
.query_map(rusqlite::params![icao.to_uppercase(), limit as i64], |row| {
Ok(ObsRow {
temp_c: row.get(0)?,
obs_time: row.get(1)?,
})
})
.ok()
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
rows
}
+153
View File
@@ -0,0 +1,153 @@
mod db;
mod model;
mod trend;
use std::sync::Arc;
use askama_axum::Template;
use axum::{
extract::State,
response::{Html, IntoResponse},
routing::get,
Router,
};
use chrono::Utc;
use model::CitySnapshot;
use tower_http::services::ServeDir;
use tracing_subscriber;
// ── city config ──
// (city_key, display_name, icao, airport_en, utc_offset_hours, threshold)
const CITIES: &[(&str, &str, &str, &str, i32, f64)] = &[
("seoul", "首尔", "RKSI", "Incheon", 9, 3.0),
("busan", "釜山", "RKPK", "Gimhae", 9, 2.0),
("tokyo", "东京", "44166", "Haneda", 9, 2.0),
("ankara", "安卡拉", "17128", "Esenboğa", 3, 3.0),
("helsinki", "赫尔辛基", "EFHK", "Vantaa", 3, 2.0),
("amsterdam","阿姆斯特丹","EHAM","Schiphol",2,2.0),
("istanbul","伊斯坦布尔","17058","Airport",3,3.0),
("paris", "巴黎", "LFPB", "Le Bourget", 2, 3.0),
("hong kong","香港","HKO","Observatory",8,1.5),
("lau fau shan","流浮山","LFS","Lau Fau Shan",8,1.5),
("taipei", "台北", "466920", "Songshan", 8, 1.5),
];
// ── app state ──
struct AppState {
db_path: String,
}
// ── templates ──
#[derive(Template)]
#[template(path = "monitor.html")]
struct MonitorTemplate {
cities: Vec<CitySnapshot>,
full_page: bool,
generated_at: String,
}
// ── data loading ──
fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh): &(&str, &str, &str, &str, i32, f64)) -> CitySnapshot {
let now_utc = Utc::now();
let local = now_utc + chrono::Duration::hours(*tz as i64);
let local_time = local.format("%H:%M").to_string();
// Recent obs for temp + trend
let obs = db::get_recent_obs(db_path, icao, 120, 12);
let temps: Vec<f64> = obs.iter().filter_map(|o| o.temp_c).collect();
let current_temp = temps.first().copied();
// Trend from last 6 points
let trend_data: Vec<f64> = temps.iter().take(6).copied().collect();
let trend = trend::calc_trend(&trend_data);
// Today's max: max of all temps in recent window (approximation)
let today_max = temps.iter().cloned().fold(None::<f64>, |a, b| {
Some(a.map_or(b, |x| x.max(b)))
});
let new_high = match (current_temp, today_max) {
(Some(ct), Some(tm)) => ct >= tm + 0.3,
_ => false,
};
// For AMOS cities, we'd need runway data from a different DB table.
// Simplified: no runway pairs for now; can be added later.
let runway_pairs: Vec<(String, f64)> = vec![];
CitySnapshot {
name: key.to_string(),
display_name: display.to_string(),
airport: airport.to_string(),
icao: icao.to_string(),
local_time,
current_temp,
today_max,
max_time: None,
trend,
new_high,
runway_pairs,
gap: None,
threshold: *thresh,
time_ok: false,
temp_ok: false,
trend_ok: false,
in_window: false,
}
}
fn load_all_cities(db_path: &str) -> Vec<CitySnapshot> {
CITIES
.iter()
.map(|c| load_city_snapshot(db_path, c))
.collect()
}
// ── routes ──
async fn index(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let cities = load_all_cities(&state.db_path);
let tmpl = MonitorTemplate {
cities,
full_page: true,
generated_at: Utc::now().format("%H:%M:%S UTC").to_string(),
};
Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}")))
}
async fn api_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let cities = load_all_cities(&state.db_path);
let tmpl = MonitorTemplate {
cities,
full_page: false,
generated_at: Utc::now().format("%H:%M:%S UTC").to_string(),
};
Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}")))
}
// ── main ──
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let db_path = std::env::var("POLYWEATHER_DB_PATH")
.unwrap_or_else(|_| "/var/lib/polyweather/polyweather.db".into());
let listen = std::env::var("MONITOR_LISTEN_ADDR")
.unwrap_or_else(|_| "0.0.0.0:3001".into());
tracing::info!("DB path: {db_path}");
tracing::info!("市场监控页面: http://{listen}");
let state = Arc::new(AppState { db_path });
let app = Router::new()
.route("/", get(index))
.route("/api/data", get(api_data))
.nest_service("/static", ServeDir::new("static"))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&listen).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
+51
View File
@@ -0,0 +1,51 @@
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct CitySnapshot {
pub name: String,
pub display_name: String,
pub airport: String,
pub icao: String,
pub local_time: String,
pub current_temp: Option<f64>,
pub today_max: Option<f64>,
pub max_time: Option<String>,
pub trend: Trend,
pub new_high: bool,
pub runway_pairs: Vec<(String, f64)>,
pub gap: Option<f64>,
pub threshold: f64,
pub time_ok: bool,
pub temp_ok: bool,
pub trend_ok: bool,
pub in_window: bool,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Trend {
Rising,
Falling,
Flat,
Unknown,
}
impl Trend {
pub fn symbol(&self) -> &str {
match self {
Trend::Rising => "",
Trend::Falling => "",
Trend::Flat => "",
Trend::Unknown => "",
}
}
pub fn css_class(&self) -> &str {
match self {
Trend::Rising => "rising",
Trend::Falling => "falling",
Trend::Flat => "flat",
Trend::Unknown => "",
}
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::model::Trend;
/// Calculate temperature trend from recent observations.
/// Uses linear regression slope. Observations are newest-first.
pub fn calc_trend(temps: &[f64]) -> Trend {
let n = temps.len();
if n < 4 {
return Trend::Unknown;
}
// Reverse to oldest-first for regression
let values: Vec<f64> = temps.iter().rev().copied().collect();
let n_f = n as f64;
let mean_x = (n_f - 1.0) / 2.0;
let mean_y = values.iter().sum::<f64>() / n_f;
let mut num = 0.0;
let mut den = 0.0;
for (i, &y) in values.iter().enumerate() {
let x = i as f64;
num += (x - mean_x) * (y - mean_y);
den += (x - mean_x) * (x - mean_x);
}
if den == 0.0 {
return Trend::Flat;
}
let slope = num / den;
// Slope is °C per observation interval (~10 min per obs from METAR cluster)
// Threshold: > +0.2 → rising, < -0.2 → falling
if slope > 0.2 {
Trend::Rising
} else if slope < -0.2 {
Trend::Falling
} else {
Trend::Flat
}
}