删除 Rust 监控项目及旧文档,已用 Python 重写替代
This commit is contained in:
@@ -1,282 +0,0 @@
|
||||
# 市场监控频道 — Rust 独立网页版
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
浏览器 http://VPS:3001
|
||||
│
|
||||
▼
|
||||
Rust 独立进程 (Axum + Askama + HTMX)
|
||||
│ 直接读 SQLite
|
||||
▼
|
||||
polyweather.db ← Python 采集管道写入
|
||||
└ airport_obs_log 表
|
||||
└ deb_predictions 表(今日最高实测)
|
||||
```
|
||||
|
||||
零 Python 依赖。Rust 直接从 SQLite 拿温度数据,自给自足。
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 层 | 库 | 理由 |
|
||||
|---|---|---|
|
||||
| HTTP 服务 | **Axum** 0.8 | Tokio 生态,类型安全路由 |
|
||||
| 模板引擎 | **Askama** 0.12 | 编译期检查模板,零运行时开销 |
|
||||
| SQLite | **rusqlite** | 读 airport_obs_log 拿温度趋势 |
|
||||
| 自动更新 | **HTMX** | 一个 `<script>` 标签,`hx-get` + `hx-trigger` 轮询 |
|
||||
| 样式 | 手写 CSS | 暗色主题,无框架依赖 |
|
||||
|
||||
编译出来 ~10MB 二进制,内存 < 25MB。
|
||||
|
||||
## 展示字段(精简)
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| 城市名 + 机场名 | config 写死 | 11 个城市固定映射 |
|
||||
| 当地时间 | 后台实时计算 | UTC + 时区偏移 |
|
||||
| 当前温度 | `airport_obs_log` 最近一条 | 大字显示 |
|
||||
| 今日实测最高 | DB 或配置文件 | 日常刷新 |
|
||||
| 趋势 | 最近 6 条 obs 线性拟合 | 升 ↑ / 降 ↓ / 平 → |
|
||||
| 跑道温度 | 首尔/釜山 AMOS 数据 | 有多条跑道时展示 |
|
||||
| 新高温标记 | 当前 >= 今日最高 + 0.3 | 蓝紫色角标 |
|
||||
|
||||
## 页面布局
|
||||
|
||||
一屏扫完 11 城。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 🔥 市场监控 刷新 14:30:00 │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 首尔 / Incheon │ │ 东京 / Haneda │ │ 釜山 / Gimhae │ │
|
||||
│ │ 13:30 KST │ │ 14:30 JST │ │ 14:30 KST │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 28.5°C │ │ 31.2°C ◆新高 │ │ 25.8°C ↓ │ │
|
||||
│ │ 今日最高 29.1°C │ │ 今日最高 31.2°C │ │ 今日最高 26.5°C │ │
|
||||
│ │ ↑ │ │ ↑ │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 18L/36R 28.5°C │ │ │ │ 18L/36R 25.8°C │ │
|
||||
│ │ 18R/36L 27.9°C │ │ │ │ 18R/36L 25.1°C │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 安卡拉 / Esenboğa│ │赫尔辛基 / Vantaa │ │阿姆斯特丹/Schiphol│ │
|
||||
│ │ ... │ │ ... │ │ ... │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │伊斯坦布尔/Airport│ │ 巴黎 /Le Bourget │ │ 香港 / Observatory│ │
|
||||
│ │ ... │ │ ... │ │ ... │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 流浮山/LauFauShan│ │ 台北 / Songshan │ │
|
||||
│ │ ... │ │ ... │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 单张卡片
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ 首尔 / Incheon │ ← 城市名 / 机场名
|
||||
│ 13:30 KST │ ← 当地时间
|
||||
│ │
|
||||
│ 28.5°C │ ← 大字当前温度
|
||||
│ │
|
||||
│ 今日最高 29.1°C ↑ │ ← 最高温 + 趋势箭头
|
||||
│ │
|
||||
│ ── 跑道 ── │ ← 首尔/釜山有
|
||||
│ 18L/36R 28.5°C │
|
||||
│ 18R/36L 27.9°C │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
温度较高或触达新高时,温度数字用暖色高亮。趋势指示用箭头(↑ 绿色 / ↓ 蓝色 / → 灰色)。
|
||||
|
||||
## 数据源
|
||||
|
||||
直接读 SQLite `airport_obs_log` 表:
|
||||
|
||||
```sql
|
||||
SELECT temp_c, obs_time, created_at
|
||||
FROM airport_obs_log
|
||||
WHERE icao = ? AND created_at > datetime('now', '-2 hours')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 12;
|
||||
```
|
||||
|
||||
- 最新一条 → 当前温度
|
||||
- 最近 6 条 → 算趋势(线性拟合斜率)
|
||||
- 今日最高从 DB 或单独配置拿
|
||||
|
||||
11 个城市的 ICAO 映射写死在 Rust config 里(与 telegram_push.py 一致):
|
||||
|
||||
```rust
|
||||
const CITIES: &[(&str, &str, &str, &str, i32)] = &[
|
||||
("seoul", "首尔", "RKSI", "Incheon", 9),
|
||||
("busan", "釜山", "RKPK", "Gimhae", 9),
|
||||
("tokyo", "东京", "44166", "Haneda", 9),
|
||||
("ankara", "安卡拉", "17128", "Esenboğa", 3),
|
||||
("helsinki", "赫尔辛基", "EFHK", "Vantaa", 3),
|
||||
("amsterdam","阿姆斯特丹", "EHAM", "Schiphol", 2),
|
||||
("istanbul", "伊斯坦布尔", "17058", "Airport", 3),
|
||||
("paris", "巴黎", "LFPB", "Le Bourget", 2),
|
||||
("hong kong","香港", "HKO", "Observatory", 8),
|
||||
("lau fau shan","流浮山", "LFS", "Lau Fau Shan", 8),
|
||||
("taipei", "台北", "466920", "Songshan", 8),
|
||||
];
|
||||
```
|
||||
|
||||
## Rust 项目结构
|
||||
|
||||
```
|
||||
monitoring-web/ ← 放在 PolyWeather 仓库根目录下
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs ← Axum 启动,路由注册
|
||||
│ ├── db.rs ← SQLite 查询(rusqlite)
|
||||
│ ├── model.rs ← CitySnapshot 数据结构
|
||||
│ ├── trend.rs ← 温度趋势计算
|
||||
│ └── templates/
|
||||
│ └── monitor.html ← Askama 模板(完整页面)
|
||||
└── static/
|
||||
└── style.css
|
||||
```
|
||||
|
||||
### `Cargo.toml`
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "market-monitor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
askama = { version = "0.12", features = ["with-axum"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
chrono = "0.4"
|
||||
```
|
||||
|
||||
### 核心逻辑伪代码
|
||||
|
||||
```rust
|
||||
// main.rs
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let db_path = std::env::var("POLYWEATHER_DB_PATH")
|
||||
.unwrap_or_else(|_| "data/polyweather.db".into());
|
||||
let state = Arc::new(AppState { db_path });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index)) // 完整页面
|
||||
.route("/api/data", get(cards)) // HTMX 轮询
|
||||
.nest_service("/static", ServeDir::new("static"))
|
||||
.with_state(state);
|
||||
|
||||
let addr = std::env::var("MONITOR_LISTEN_ADDR")
|
||||
.unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||
tracing::info!("市场监控: http://{}", addr);
|
||||
axum::serve(TcpListener::bind(&addr).await.unwrap(), app).await.unwrap();
|
||||
}
|
||||
|
||||
// GET / → 渲染完整 HTML 页面
|
||||
async fn index(state: State<Arc<AppState>>) -> Html<String> {
|
||||
let cities = load_all_cities(&state.db_path).await;
|
||||
let tmpl = MonitorTemplate { cities, full_page: true };
|
||||
Html(tmpl.render().unwrap())
|
||||
}
|
||||
|
||||
// GET /api/data → HTMX 轮询,只返回卡片网格(无完整 HTML 壳)
|
||||
async fn cards(state: State<Arc<AppState>>) -> Html<String> {
|
||||
let cities = load_all_cities(&state.db_path).await;
|
||||
let tmpl = MonitorTemplate { cities, full_page: false };
|
||||
Html(tmpl.render().unwrap())
|
||||
}
|
||||
```
|
||||
|
||||
### DB 查询
|
||||
|
||||
```rust
|
||||
// db.rs
|
||||
fn get_recent_obs(db_path: &str, icao: &str, limit: usize) -> Vec<Obs> {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT temp_c, obs_time, created_at
|
||||
FROM airport_obs_log
|
||||
WHERE icao = ?1 AND created_at > datetime('now', '-3 hours')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?2"
|
||||
)?;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 趋势计算
|
||||
|
||||
```rust
|
||||
// trend.rs
|
||||
enum Trend { Rising, Falling, Flat }
|
||||
|
||||
fn calc_trend(obs: &[Obs]) -> Trend {
|
||||
// 最近 6 条温度做线性回归
|
||||
// 斜率 > +0.2 → Rising (↑)
|
||||
// 斜率 < -0.2 → Falling (↓)
|
||||
// 否则 → Flat (→)
|
||||
}
|
||||
```
|
||||
|
||||
### HTMX 轮询
|
||||
|
||||
```html
|
||||
<!-- monitor.html -->
|
||||
<div id="card-grid"
|
||||
hx-get="/api/data"
|
||||
hx-trigger="every 60s"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#spinner">
|
||||
{% for city in cities %}
|
||||
<div class="card">
|
||||
<!-- 卡片内容 -->
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="spinner" class="htmx-indicator">刷新中...</div>
|
||||
```
|
||||
|
||||
HTMX 从 CDN 加载,一个 `<script>` 标签搞定。
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
cd monitoring-web
|
||||
cargo build --release
|
||||
|
||||
# 环境变量
|
||||
export POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
export MONITOR_LISTEN_ADDR=0.0.0.0:3001
|
||||
|
||||
./target/release/market-monitor &
|
||||
```
|
||||
|
||||
systemd unit 或 supervisor 托管即可。不占资源,VPS 上完全无感。
|
||||
|
||||
## 与 Push 的对比
|
||||
|
||||
| | Telegram Push | 网页监控 |
|
||||
|---|---|---|
|
||||
| 目的 | 精准触达:该不该发消息 | 全景扫描:一眼看 11 城 |
|
||||
| 逻辑 | DEB + 三条件 + 去重 | 温度 + 趋势 + 最高 |
|
||||
| 频率 | 条件触发 | 60s 轮询 |
|
||||
| 数据源 | `_analyze()` 完整分析 | SQLite 直接读 |
|
||||
| 用户 | 频道订阅者 | 打开网页的任何人 |
|
||||
|
||||
两者互补,不冲突。Push 仍然跑,网页给想主动扫一眼的人。
|
||||
@@ -1 +0,0 @@
|
||||
target/
|
||||
Generated
-1173
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "market-monitor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
askama = "0.12"
|
||||
askama_axum = "0.4"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
chrono = "0.4"
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=PolyWeather Market Monitor Web
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/PolyWeather/monitoring-web
|
||||
Environment=POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
Environment=MONITOR_LISTEN_ADDR=0.0.0.0:3001
|
||||
ExecStart=/root/PolyWeather/monitoring-web/target/release/market-monitor
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,87 +0,0 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObsRow {
|
||||
pub temp_c: Option<f64>,
|
||||
#[allow(dead_code)]
|
||||
pub obs_time: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Get today's running max temperature from intraday snapshots.
|
||||
pub fn get_daily_max(db_path: &str, city_key: &str) -> Option<(f64, Option<String>)> {
|
||||
let conn = Connection::open(db_path).ok()?;
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT max_so_far, snapshot_time FROM intraday_path_snapshots_store \
|
||||
WHERE city = ?1 AND target_date = ?2 \
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
)
|
||||
.ok()?;
|
||||
stmt.query_row(rusqlite::params![city_key, today], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Get runway-level observations for Seoul/Busan (stored as ICAO_RWY_N).
|
||||
pub fn get_runway_temps(db_path: &str, icao: &str) -> Vec<(String, f64)> {
|
||||
let conn = match Connection::open(db_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let pattern = format!("{}_RWY_%", icao.to_uppercase());
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT icao, temp_c FROM airport_obs_log \
|
||||
WHERE icao LIKE ?1 AND created_at > datetime('now', '-120 minutes') \
|
||||
ORDER BY icao, created_at DESC"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let mut results: Vec<(String, f64)> = vec![];
|
||||
let rows = stmt.query_map(rusqlite::params![pattern], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
|
||||
});
|
||||
if let Ok(iter) = rows {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for r in iter.filter_map(|r| r.ok()) {
|
||||
// Take only the first (most recent) per unique icao
|
||||
if seen.insert(r.0.clone()) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// 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, created_at 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)?,
|
||||
created_at: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default();
|
||||
rows
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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, runway_count)
|
||||
const CITIES: &[(&str, &str, &str, &str, &str, i32, f64, &str, usize)] = &[
|
||||
("seoul", "首尔", "Seoul", "RKSI", "Incheon", 9, 3.0, "KST", 2),
|
||||
("busan", "釜山", "Busan", "RKPK", "Gimhae", 9, 2.0, "KST", 2),
|
||||
("tokyo", "东京", "Tokyo", "44166", "Haneda", 9, 2.0, "JST", 0),
|
||||
("ankara", "安卡拉", "Ankara", "17128", "Esenboğa", 3, 3.0, "TRT", 0),
|
||||
("helsinki", "赫尔辛基", "Helsinki", "EFHK", "Vantaa", 3, 2.0, "EEST", 0),
|
||||
("amsterdam","阿姆斯特丹","Amsterdam","EHAM","Schiphol",2,2.0,"CEST", 0),
|
||||
("istanbul","伊斯坦布尔","Istanbul","17058","Airport",3,3.0,"TRT", 0),
|
||||
("paris", "巴黎", "Paris", "LFPB", "Le Bourget", 2, 3.0, "CEST", 0),
|
||||
("hong kong","香港","Hong Kong","HKO","Observatory",8,1.5,"HKT", 0),
|
||||
("lau fau shan","流浮山","Lau Fau Shan","LFS","Lau Fau Shan",8,1.5,"HKT", 0),
|
||||
("taipei", "台北", "Taipei", "466920", "Songshan", 8, 1.5, "TST", 0),
|
||||
];
|
||||
|
||||
// ── 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,
|
||||
}
|
||||
|
||||
// ── obs_time parsing ──
|
||||
|
||||
fn parse_obs_time(raw: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
let s = raw.trim();
|
||||
// ISO with timezone: "2026-05-13T14:30:00+08:00" or "2026-05-13T14:30:00Z"
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||
return Some(dt.into());
|
||||
}
|
||||
// ISO naive: "2026-05-13T14:30:00"
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(dt.and_utc());
|
||||
}
|
||||
// ISO with microseconds: "2026-05-13T14:30:00.123456"
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||
return Some(dt.and_utc());
|
||||
}
|
||||
// Epoch seconds (METAR cluster)
|
||||
if let Ok(secs) = s.parse::<f64>() {
|
||||
if (1_000_000_000.0..3_000_000_000.0).contains(&secs) {
|
||||
return chrono::DateTime::from_timestamp(secs as i64, 0);
|
||||
}
|
||||
if (100_000.0..1_000_000_000.0).contains(&secs) {
|
||||
return chrono::DateTime::from_timestamp(secs as i64, 0);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── data loading ──
|
||||
|
||||
fn load_city_snapshot(db_path: &str, _idx: usize, (key, _zh, en, icao, airport, tz, thresh, tz_abbr, rw_count): &(&str, &str, &str, &str, &str, i32, f64, &str, usize)) -> CitySnapshot {
|
||||
let now_utc = Utc::now();
|
||||
let offset = chrono::FixedOffset::east_opt(*tz * 3600).unwrap();
|
||||
|
||||
// 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();
|
||||
|
||||
// Observation time from the most recent record's obs_time field
|
||||
let local_time = obs.first().and_then(|o| {
|
||||
o.obs_time.as_ref().and_then(|raw| parse_obs_time(raw))
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback: parse created_at
|
||||
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| dt.and_utc())
|
||||
})
|
||||
})
|
||||
})
|
||||
.map(|obs_utc| {
|
||||
let local = obs_utc.with_timezone(&offset);
|
||||
format!("{} {}", local.format("%H:%M"), tz_abbr)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
let local = now_utc + chrono::Duration::hours(*tz as i64);
|
||||
format!("{} {}", local.format("%H:%M"), tz_abbr)
|
||||
});
|
||||
|
||||
// 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<f64> = temps.iter().take(6).copied().collect();
|
||||
let trend = trend::calc_trend(&trend_data);
|
||||
|
||||
// Today's max: from intraday_path_snapshots_store (实时更新)
|
||||
let (today_max, max_time) = db::get_daily_max(db_path, key)
|
||||
.map(|(t, mt)| (Some(t), mt))
|
||||
.unwrap_or((None, None));
|
||||
let new_high = match (current_temp, today_max) {
|
||||
(Some(ct), Some(tm)) => ct >= tm + 0.3,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// Runway data: query all ICAO_RWY_* for this airport
|
||||
let runway_pairs: Vec<(String, f64)> = if *rw_count > 0 {
|
||||
db::get_runway_temps(db_path, icao)
|
||||
.into_iter()
|
||||
.map(|(rw_icao, temp)| {
|
||||
let label = rw_icao.strip_prefix(&format!("{}_RWY_", icao.to_uppercase()))
|
||||
.map(|n| format!("RWY {}", n))
|
||||
.unwrap_or_else(|| rw_icao);
|
||||
(label, temp)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
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,
|
||||
trend,
|
||||
new_high,
|
||||
runway_pairs,
|
||||
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<CitySnapshot> {
|
||||
let mut cities: Vec<CitySnapshot> = CITIES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| load_city_snapshot(db_path, i, c))
|
||||
.collect();
|
||||
// 按当前温度从高到低排序,无数据的排最后
|
||||
cities.sort_by(|a, b| {
|
||||
b.current_temp
|
||||
.partial_cmp(&a.current_temp)
|
||||
.unwrap_or(std::cmp::Ordering::Less)
|
||||
});
|
||||
cities
|
||||
}
|
||||
|
||||
// ── 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}")))
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CitySnapshot {
|
||||
pub name: String,
|
||||
pub en_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,
|
||||
pub obs_age_min: Option<i64>,
|
||||
pub temp_warm: 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 => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
|
||||
body {
|
||||
background: #0f1117;
|
||||
color: #c8cdd4;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid #1e2130;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #e8eaed;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.notify-btn {
|
||||
background: none;
|
||||
border: 1px solid #2a2e40;
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.notify-btn:hover { border-color: #4a5160; }
|
||||
.notify-btn.muted { opacity: 0.4; }
|
||||
|
||||
/* ── grid ── */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 600px) { .card-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── card ── */
|
||||
.card {
|
||||
background: #161822;
|
||||
border: 1px solid #1e2130;
|
||||
border-radius: 12px;
|
||||
padding: 22px 26px;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #2a2e40;
|
||||
}
|
||||
|
||||
/* ── card-top ── */
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.city-name {
|
||||
color: #e0e3e8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.airport {
|
||||
color: #6a7180;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.local-time {
|
||||
margin-left: auto;
|
||||
color: #4a5160;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-high {
|
||||
background: rgba(124, 58, 237, 0.18);
|
||||
color: #a78bfa;
|
||||
}
|
||||
.new-high-card {
|
||||
border-color: rgba(124, 58, 237, 0.30);
|
||||
}
|
||||
|
||||
/* ── temp ── */
|
||||
.card-temp {
|
||||
margin: 8px 0 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 52px;
|
||||
color: #e8eaed;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.temp-value.na {
|
||||
color: #3a4050;
|
||||
font-size: 32px;
|
||||
}
|
||||
.temp-value.warm {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.temp-value.new-high-val {
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.temp-unit {
|
||||
font-size: 22px;
|
||||
color: #5a6170;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
/* ── meta ── */
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-row .label {
|
||||
color: #4a5160;
|
||||
}
|
||||
|
||||
.meta-row .value {
|
||||
color: #9aa0b0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta-row .value.na {
|
||||
color: #3a4050;
|
||||
}
|
||||
.meta-row .trend {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── trend ── */
|
||||
.trend { font-size: 18px; font-weight: 700; }
|
||||
.trend.rising { color: #34d399; }
|
||||
.trend.falling { color: #60a5fa; }
|
||||
.trend.flat { color: #5a6170; }
|
||||
|
||||
/* ── runway ── */
|
||||
.card-runway { margin-top: 12px; }
|
||||
|
||||
.runway-divider {
|
||||
height: 1px;
|
||||
background: #1e2130;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runway-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.runway-label { color: #4a5160; }
|
||||
.runway-temp { color: #7a8290; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── obs age ── */
|
||||
.obs-age { font-size: 13px; color: #5a6170; }
|
||||
.max-time { font-size: 12px; color: #4a5160; margin-left: 2px; }
|
||||
|
||||
/* ── HTMX indicator ── */
|
||||
.htmx-indicator {
|
||||
text-align: center;
|
||||
padding: 14px;
|
||||
font-size: 14px;
|
||||
color: #3a4050;
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator { display: block; }
|
||||
@@ -1,178 +0,0 @@
|
||||
{% if full_page %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Market Monitor — PolyWeather</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<meta http-equiv="refresh" content="120">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<h1>🔥 Market Monitor</h1>
|
||||
<div class="header-right">
|
||||
<button id="notify-toggle" class="notify-btn" onclick="toggleNotify()" title="新高提醒">
|
||||
<span id="notify-icon">🔔</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<div id="card-grid" class="card-grid" hx-get="/api/data" hx-trigger="every 30s" hx-swap="outerHTML"
|
||||
hx-indicator="#spinner">
|
||||
<span class="updated" style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px">
|
||||
{{ generated_at }}</span>
|
||||
{% for c in cities %}
|
||||
<div class="card{% if c.new_high %} new-high-card{% endif %}" data-new-high="{{ c.new_high }}">
|
||||
<div class="card-top">
|
||||
<span class="city-name">{{ c.en_name }}</span>
|
||||
<span class="airport">/ {{ c.airport }}</span>
|
||||
<span class="local-time">{{ c.local_time }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-temp">
|
||||
{% match c.current_temp %}
|
||||
{% when Some with (t) %}
|
||||
<span class="temp-value{% if c.temp_warm %} warm{% endif %}{% if c.new_high %} new-high-val{% endif %}">{{
|
||||
"{:.1}"|format(t) }}</span><span class="temp-unit">°C</span>
|
||||
{% when None %}
|
||||
<span class="temp-value na">--</span>
|
||||
{% endmatch %}
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<div class="meta-row">
|
||||
<span class="label">High</span>
|
||||
{% match c.today_max %}
|
||||
{% when Some with (m) %}
|
||||
<span class="value">{{ "{:.1}"|format(m) }}°C</span>
|
||||
{% match c.max_time %}
|
||||
{% when Some with (mt) %}
|
||||
<span class="value max-time">{{ mt }}</span>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
{% when None %}
|
||||
<span class="value na">--</span>
|
||||
{% endmatch %}
|
||||
<span class="trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="label">Obs</span>
|
||||
{% match c.obs_age_min %}
|
||||
{% when Some with (m) %}
|
||||
<span class="value obs-age">{{ m }} min ago</span>
|
||||
{% when None %}
|
||||
<span class="value na">--</span>
|
||||
{% endmatch %}
|
||||
<span class="trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if !c.runway_pairs.is_empty() %}
|
||||
<div class="card-runway">
|
||||
<div class="runway-divider"></div>
|
||||
{% for (label, temp) in c.runway_pairs %}
|
||||
<div class="runway-row">
|
||||
<span class="runway-label">{{ label }}</span>
|
||||
<span class="runway-temp">{{ "{:.1}"|format(temp) }}°C</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="spinner" class="htmx-indicator">Refreshing…</div>
|
||||
|
||||
{% if full_page %}
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script>
|
||||
// ── Notification ──
|
||||
var NOTIFY_ENABLED = localStorage.getItem('monitor_notify') !== 'off';
|
||||
var NOTIFIED_HIGHS = JSON.parse(localStorage.getItem('monitor_notified_highs') || '{}');
|
||||
|
||||
function toggleNotify() {
|
||||
NOTIFY_ENABLED = !NOTIFY_ENABLED;
|
||||
localStorage.setItem('monitor_notify', NOTIFY_ENABLED ? 'on' : 'off');
|
||||
updateNotifyIcon();
|
||||
if (NOTIFY_ENABLED && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
function updateNotifyIcon() {
|
||||
var icon = document.getElementById('notify-icon');
|
||||
var btn = document.getElementById('notify-toggle');
|
||||
if (NOTIFY_ENABLED) {
|
||||
icon.textContent = '🔔';
|
||||
btn.classList.remove('muted');
|
||||
} else {
|
||||
icon.textContent = '🔕';
|
||||
btn.classList.add('muted');
|
||||
}
|
||||
}
|
||||
|
||||
function fireNotification(enName, temp) {
|
||||
if (!NOTIFY_ENABLED) return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
|
||||
var key = enName + '|' + temp;
|
||||
var today = new Date().toDateString();
|
||||
|
||||
// Reset notified list on new day
|
||||
if (NOTIFIED_HIGHS._day !== today) {
|
||||
NOTIFIED_HIGHS = { _day: today };
|
||||
}
|
||||
if (NOTIFIED_HIGHS[key]) return; // already notified
|
||||
|
||||
NOTIFIED_HIGHS[key] = true;
|
||||
localStorage.setItem('monitor_notified_highs', JSON.stringify(NOTIFIED_HIGHS));
|
||||
|
||||
new Notification('🔴 New High — ' + enName, {
|
||||
body: temp + '°C\nNew daily high.',
|
||||
icon: '/static/favicon.png',
|
||||
tag: key,
|
||||
requireInteraction: true,
|
||||
});
|
||||
}
|
||||
|
||||
updateNotifyIcon();
|
||||
|
||||
// ── Flash on temp change + notifications ──
|
||||
(function () {
|
||||
let prev = {};
|
||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||
if (evt.detail.target.id !== 'card-grid') return;
|
||||
document.querySelectorAll('.card').forEach(function (card) {
|
||||
var name = card.querySelector('.city-name').textContent;
|
||||
var tv = card.querySelector('.temp-value').textContent;
|
||||
var old = prev[name];
|
||||
prev[name] = tv;
|
||||
|
||||
// Flash if changed
|
||||
if (old && old !== tv && tv !== '--') {
|
||||
card.style.transition = 'none';
|
||||
card.style.boxShadow = '0 0 14px rgba(52,211,153,0.40)';
|
||||
requestAnimationFrame(function () {
|
||||
card.style.transition = 'box-shadow 2s ease-out';
|
||||
card.style.boxShadow = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Notification for new high
|
||||
if (card.dataset.newHigh === 'true') {
|
||||
fireNotification(name, tv);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user