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 ── // (key, zh_name, en_name, icao, airport_en, utc_offset_hours, threshold, tz_abbr) const CITIES: &[(&str, &str, &str, &str, &str, i32, f64, &str)] = &[ ("seoul", "首尔", "Seoul", "RKSI", "Incheon", 9, 3.0, "KST"), ("busan", "釜山", "Busan", "RKPK", "Gimhae", 9, 2.0, "KST"), ("tokyo", "东京", "Tokyo", "44166", "Haneda", 9, 2.0, "JST"), ("ankara", "安卡拉", "Ankara", "17128", "Esenboğa", 3, 3.0, "TRT"), ("helsinki", "赫尔辛基", "Helsinki", "EFHK", "Vantaa", 3, 2.0, "EEST"), ("amsterdam","阿姆斯特丹","Amsterdam","EHAM","Schiphol",2,2.0,"CEST"), ("istanbul","伊斯坦布尔","Istanbul","17058","Airport",3,3.0,"TRT"), ("paris", "巴黎", "Paris", "LFPB", "Le Bourget", 2, 3.0, "CEST"), ("hong kong","香港","Hong Kong","HKO","Observatory",8,1.5,"HKT"), ("lau fau shan","流浮山","Lau Fau Shan","LFS","Lau Fau Shan",8,1.5,"HKT"), ("taipei", "台北", "Taipei", "466920", "Songshan", 8, 1.5, "TST"), ]; // ── app state ── struct AppState { db_path: String, } // ── templates ── #[derive(Template)] #[template(path = "monitor.html")] struct MonitorTemplate { cities: Vec, full_page: bool, generated_at: String, } // ── data loading ── fn load_city_snapshot(db_path: &str, (key, _zh, en, icao, airport, tz, thresh, tz_abbr): &(&str, &str, &str, &str, &str, i32, f64, &str)) -> CitySnapshot { let now_utc = Utc::now(); let local = now_utc + chrono::Duration::hours(*tz as i64); let local_time = format!("{} {}", local.format("%H:%M"), tz_abbr); // Recent obs for temp + trend let obs = db::get_recent_obs(db_path, icao, 120, 12); let temps: Vec = obs.iter().filter_map(|o| o.temp_c).collect(); let current_temp = temps.first().copied(); // Age of most recent observation let obs_age_min = obs.first().and_then(|o| { o.created_at.as_ref().and_then(|ts| { chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") .ok() .map(|dt| { let obs_utc = dt.and_utc(); (now_utc - obs_utc).num_minutes().max(0) }) }) }); // Trend from last 6 points let trend_data: Vec = 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::, |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, }; CitySnapshot { name: key.to_string(), en_name: en.to_string(), airport: airport.to_string(), icao: icao.to_string(), local_time, current_temp, today_max, max_time: None, trend, new_high, runway_pairs: vec![], gap: None, threshold: *thresh, time_ok: false, temp_ok: false, trend_ok: false, in_window: false, obs_age_min, temp_warm: current_temp.map_or(false, |t| t >= 30.0), } } fn load_all_cities(db_path: &str) -> Vec { CITIES .iter() .map(|c| load_city_snapshot(db_path, c)) .collect() } // ── routes ── async fn index(State(state): State>) -> 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>) -> 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}"))) } #[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(); }