[verified] feat: add live river dashboard
Add mapped river and ThaiWater sensor layers, PostgreSQL history charts, API endpoints, and dashboard tests.
This commit is contained in:
@@ -37,6 +37,7 @@ class Config:
|
||||
# Website settings
|
||||
TARGET_URL = "https://hyd-app-db.rid.go.th/hydro1h.html"
|
||||
API_URL = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx"
|
||||
THAIWATER_API_KEY = os.getenv("THAIWATER_API_KEY")
|
||||
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Read historical station measurements from PostgreSQL."""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
class PostgresHistory:
|
||||
def __init__(self, connection_string: Optional[str] = None, engine=None):
|
||||
connection_string = connection_string or os.getenv("POSTGRES_CONNECTION_STRING")
|
||||
if engine is None and not connection_string:
|
||||
raise RuntimeError("POSTGRES_CONNECTION_STRING is not configured")
|
||||
self.engine = engine or create_engine(connection_string, pool_pre_ping=True)
|
||||
|
||||
def station_history(
|
||||
self,
|
||||
station_code: str,
|
||||
start: datetime.datetime,
|
||||
end: datetime.datetime,
|
||||
limit: int = 2000,
|
||||
) -> List[Dict]:
|
||||
if not 1 <= limit <= 5000:
|
||||
raise ValueError("limit must be between 1 and 5000")
|
||||
if start >= end:
|
||||
raise ValueError("start must be before end")
|
||||
|
||||
query = text(
|
||||
"""
|
||||
SELECT m.timestamp, s.station_code, m.water_level,
|
||||
m.discharge, m.discharge_percent
|
||||
FROM water_measurements m
|
||||
JOIN stations s ON m.station_id = s.id
|
||||
WHERE s.station_code = :station_code
|
||||
AND m.timestamp >= :start_time
|
||||
AND m.timestamp <= :end_time
|
||||
ORDER BY m.timestamp ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
with self.engine.connect() as connection:
|
||||
rows = connection.execute(
|
||||
query,
|
||||
{
|
||||
"station_code": station_code,
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
timestamp = row[0]
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.datetime.fromisoformat(timestamp)
|
||||
result.append(
|
||||
{
|
||||
"timestamp": timestamp,
|
||||
"station_code": row[1],
|
||||
"water_level": float(row[2]) if row[2] is not None else None,
|
||||
"discharge": float(row[3]) if row[3] is not None else None,
|
||||
"discharge_percent": float(row[4]) if row[4] is not None else None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
+448
-39
@@ -1,50 +1,459 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Northern Thailand Ping River Monitor</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ping River Live Monitor</title>
|
||||
<link rel="preconnect" href="https://unpkg.com">
|
||||
<link rel="preconnect" href="https://tile.openstreetmap.org">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" crossorigin="anonymous">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.header { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
|
||||
.section { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 5px; }
|
||||
.status-healthy { color: #27ae60; }
|
||||
.status-degraded { color: #f39c12; }
|
||||
.status-unhealthy { color: #e74c3c; }
|
||||
.endpoint { background: #f8f9fa; padding: 10px; margin: 5px 0; border-radius: 3px; }
|
||||
.endpoint code { color: #2c3e50; }
|
||||
:root {
|
||||
--ink: #132b35;
|
||||
--muted: #64777d;
|
||||
--paper: #f3f7f5;
|
||||
--card: #ffffff;
|
||||
--river: #087da5;
|
||||
--river-light: #38b4d5;
|
||||
--mint: #dff4e8;
|
||||
--green: #1e8b60;
|
||||
--amber: #d99018;
|
||||
--red: #cc4b37;
|
||||
--border: #dce7e3;
|
||||
--shadow: 0 16px 40px rgba(23, 57, 67, .10);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(56, 180, 213, .12), transparent 25rem),
|
||||
var(--paper);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.shell { max-width: 1500px; margin: 0 auto; padding: 24px; }
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; }
|
||||
.brand-mark {
|
||||
width: 48px; height: 48px; border-radius: 15px; display: grid; place-items: center;
|
||||
color: white; font-size: 25px; background: linear-gradient(145deg, #0a91b9, #076787);
|
||||
box-shadow: 0 10px 22px rgba(8, 125, 165, .25);
|
||||
}
|
||||
h1 { margin: 0; font-size: clamp(1.4rem, 2.5vw, 2rem); letter-spacing: -.035em; }
|
||||
.subtitle { margin: 4px 0 0; color: var(--muted); font-size: .92rem; }
|
||||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.live-pill {
|
||||
display: flex; gap: 8px; align-items: center; padding: 9px 13px; border-radius: 999px;
|
||||
background: var(--mint); color: #146644; font-weight: 750; font-size: .8rem;
|
||||
}
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22a66c; box-shadow: 0 0 0 5px rgba(34,166,108,.12); }
|
||||
button {
|
||||
border: 1px solid var(--border); border-radius: 11px; background: white; color: var(--ink);
|
||||
padding: 10px 14px; cursor: pointer; font-weight: 700; box-shadow: 0 3px 10px rgba(22,52,62,.05);
|
||||
}
|
||||
button:hover { border-color: #a9c4bb; transform: translateY(-1px); }
|
||||
button:disabled { opacity: .55; cursor: wait; transform: none; }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
|
||||
.stat {
|
||||
min-height: 112px; padding: 18px; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 17px; box-shadow: 0 6px 18px rgba(31,61,70,.045);
|
||||
}
|
||||
.stat-label { color: var(--muted); text-transform: uppercase; letter-spacing: .09em; font-size: .68rem; font-weight: 800; }
|
||||
.stat-value { margin-top: 9px; font-size: 1.65rem; font-weight: 800; letter-spacing: -.04em; white-space: nowrap; }
|
||||
.stat-note { color: var(--muted); margin-top: 3px; font-size: .77rem; }
|
||||
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; }
|
||||
.map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
|
||||
.map-card { position: relative; }
|
||||
#station-map { height: 640px; width: 100%; background: #dcebea; }
|
||||
.map-overlay {
|
||||
position: absolute; z-index: 500; top: 16px; left: 52px; right: 16px;
|
||||
display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none;
|
||||
}
|
||||
.map-heading, .legend {
|
||||
background: rgba(255,255,255,.93); backdrop-filter: blur(9px); border: 1px solid rgba(207,224,218,.9);
|
||||
border-radius: 13px; padding: 11px 13px; box-shadow: 0 7px 20px rgba(22,58,68,.12);
|
||||
}
|
||||
.map-heading strong { display: block; font-size: .9rem; }
|
||||
.map-heading span { color: var(--muted); font-size: .72rem; }
|
||||
.legend { font-size: .7rem; color: var(--muted); }
|
||||
.legend-title { color: var(--ink); font-weight: 800; margin-bottom: 7px; }
|
||||
.legend-row { display: flex; align-items: center; gap: 6px; margin: 5px 0; }
|
||||
.swatch { width: 9px; height: 9px; border-radius: 50%; }
|
||||
.side-card { display: flex; flex-direction: column; max-height: 640px; }
|
||||
.side-head { padding: 18px 18px 14px; border-bottom: 1px solid var(--border); }
|
||||
.side-head h2 { margin: 0; font-size: 1rem; }
|
||||
.side-head p { margin: 5px 0 0; color: var(--muted); font-size: .75rem; }
|
||||
.station-list { overflow-y: auto; padding: 7px; }
|
||||
.station-row {
|
||||
width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr) auto; gap: 10px; align-items: center;
|
||||
padding: 11px; border: 0; border-radius: 12px; box-shadow: none; text-align: left; background: transparent;
|
||||
}
|
||||
.station-row:hover { background: #f1f7f5; transform: none; }
|
||||
.station-code { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; color: white; font-size: .68rem; font-weight: 850; }
|
||||
.station-name { overflow: hidden; }
|
||||
.station-name strong, .station-name span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.station-name strong { font-size: .78rem; }
|
||||
.station-name span { color: var(--muted); font-size: .68rem; margin-top: 3px; }
|
||||
.flow-value { text-align: right; font-size: .82rem; font-weight: 800; }
|
||||
.flow-value span { display: block; color: var(--muted); font-size: .61rem; font-weight: 650; margin-top: 2px; }
|
||||
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: rgba(243,247,245,.88); }
|
||||
.loading-card { background: white; padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
|
||||
.error-panel { display: none; color: #8d2f22; text-align: center; padding: 25px; }
|
||||
.marker-wrap { background: none; border: 0; }
|
||||
.flow-marker {
|
||||
--marker-color: #087da5; --marker-size: 26px;
|
||||
position: relative; width: var(--marker-size); height: var(--marker-size); display: grid; place-items: center;
|
||||
border-radius: 50%; background: var(--marker-color); color: white; border: 3px solid white;
|
||||
box-shadow: 0 4px 12px rgba(5,43,58,.35); font-size: 8px; font-weight: 900;
|
||||
}
|
||||
.flow-marker::before {
|
||||
content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid var(--marker-color);
|
||||
opacity: .36; animation: pulse 2.2s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0% { transform: scale(.72); opacity: .55; } 75%,100% { transform: scale(1.35); opacity: 0; } }
|
||||
.flow-line { animation: riverMove 1.8s linear infinite; }
|
||||
@keyframes riverMove { to { stroke-dashoffset: -30; } }
|
||||
.leaflet-popup-content-wrapper { border-radius: 14px; box-shadow: 0 12px 35px rgba(14,45,54,.2); }
|
||||
.popup { min-width: 190px; }
|
||||
.popup-code { font-size: .7rem; color: var(--river); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.popup h3 { margin: 4px 0 2px; font-size: 1rem; }
|
||||
.popup-th { color: var(--muted); font-size: .74rem; }
|
||||
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
|
||||
.popup-metric { background: #f1f7f5; padding: 8px; border-radius: 9px; }
|
||||
.popup-metric span { display: block; color: var(--muted); font-size: .62rem; }
|
||||
.popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; }
|
||||
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
|
||||
@media (max-width: 900px) {
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.side-card { max-height: 400px; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.shell { padding: 14px; }
|
||||
header { align-items: flex-start; }
|
||||
.subtitle, .live-pill { display: none; }
|
||||
.stats { gap: 8px; }
|
||||
.stat { min-height: 96px; padding: 14px; }
|
||||
.stat-value { font-size: 1.25rem; }
|
||||
#station-map { height: 540px; }
|
||||
.map-overlay { left: 46px; }
|
||||
.legend { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🏔️ Northern Thailand Ping River Monitor API</h1>
|
||||
<p>Real-time water level monitoring system for the Ping River Basin in Northern Thailand</p>
|
||||
</div>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="brand-mark">≋</div>
|
||||
<div>
|
||||
<h1>Ping River Live Monitor</h1>
|
||||
<p class="subtitle">Current water level and discharge across Northern Thailand</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="live-pill"><span class="live-dot"></span> LIVE DATA</div>
|
||||
<button id="refresh-button" type="button">↻ Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<h2>📊 Quick Status</h2>
|
||||
<p>API is running and monitoring 16 water stations along the Ping River</p>
|
||||
<p>Coverage: From Chiang Dao to Nakhon Sawan</p>
|
||||
<p>Data collection interval: Every hour</p>
|
||||
</div>
|
||||
<section class="stats" aria-label="River summary">
|
||||
<article class="stat"><div class="stat-label">Reporting stations</div><div class="stat-value" id="station-count">—</div><div class="stat-note">with current readings</div></article>
|
||||
<article class="stat"><div class="stat-label">Combined discharge</div><div class="stat-value" id="total-flow">—</div><div class="stat-note">sum of reported flows · m³/s</div></article>
|
||||
<article class="stat"><div class="stat-label">Strongest flow</div><div class="stat-value" id="peak-flow">—</div><div class="stat-note" id="peak-station">Awaiting station data</div></article>
|
||||
<article class="stat"><div class="stat-label">Last updated</div><div class="stat-value" id="last-updated">—</div><div class="stat-note" id="data-age">Loading latest readings</div></article>
|
||||
</section>
|
||||
|
||||
<div class="section">
|
||||
<h2>🔗 API Endpoints</h2>
|
||||
<div class="endpoint"><code>GET /health</code> - System health status</div>
|
||||
<div class="endpoint"><code>GET /metrics</code> - Application metrics</div>
|
||||
<div class="endpoint"><code>GET /stations</code> - List all monitoring stations</div>
|
||||
<div class="endpoint"><code>POST /stations</code> - Add new monitoring station</div>
|
||||
<div class="endpoint"><code>PUT /stations/{station_id}</code> - Update station information</div>
|
||||
<div class="endpoint"><code>GET /measurements/latest</code> - Latest measurements</div>
|
||||
<div class="endpoint"><code>GET /measurements/station/{station_code}</code> - Station-specific data</div>
|
||||
<div class="endpoint"><code>POST /scrape/trigger</code> - Trigger manual data collection</div>
|
||||
<div class="endpoint"><code>GET /scraping/status</code> - Scraping status</div>
|
||||
<div class="endpoint"><code>GET /docs</code> - Interactive API documentation</div>
|
||||
</div>
|
||||
<section class="workspace">
|
||||
<article class="map-card">
|
||||
<div id="station-map" role="application" aria-label="Interactive map of Ping River monitoring stations"></div>
|
||||
<div class="map-overlay">
|
||||
<div class="map-heading"><strong>Station flow map</strong><span>Marker size follows current discharge</span></div>
|
||||
<div class="legend">
|
||||
<div class="legend-title">Flow status</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#1e8b60"></i> Low < 25 m³/s</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#087da5"></i> Moderate 25–100</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#d99018"></i> High 100–250</div>
|
||||
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high > 250</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
|
||||
<div class="error-panel" id="error"><div><strong>Map data could not be loaded.</strong><br><span id="error-message"></span></div></div>
|
||||
</article>
|
||||
|
||||
<div class="section">
|
||||
<h2>📈 Monitoring</h2>
|
||||
<p>• Grafana dashboards available for data visualization</p>
|
||||
<p>• Health checks monitor database, API, and system resources</p>
|
||||
<p>• Metrics collection for performance monitoring</p>
|
||||
</div>
|
||||
<aside class="side-card">
|
||||
<div class="side-head"><h2>Current station flow</h2><p>Select a station to locate it and load PostgreSQL history</p></div>
|
||||
<div class="station-list" id="river-flow" aria-live="polite"></div>
|
||||
<div class="side-head"><h2>Additional ThaiWater sensors</h2><p id="thaiwater-count">Loading Ping basin sensors…</p></div>
|
||||
<div class="station-list" id="thaiwater-sensors" aria-live="polite"></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="map-card" id="history-card" style="margin-top:14px;padding:20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
|
||||
<div><h2 id="history-title" style="margin:0;font-size:1rem">PostgreSQL history</h2><p id="history-status" class="subtitle">Select a RID flow station to load the last 7 days</p></div>
|
||||
<select id="history-range" style="padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white"><option value="24">24 hours</option><option value="168" selected>7 days</option><option value="720">30 days</option><option value="2160">90 days</option></select>
|
||||
</div>
|
||||
<div style="height:260px;margin-top:14px"><canvas id="history-chart" aria-label="Historical water level and discharge chart"></canvas></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function flowColor(flow) {
|
||||
if (flow == null || Number.isNaN(flow)) return '#7b8f94';
|
||||
if (flow < 25) return '#1e8b60';
|
||||
if (flow < 100) return '#087da5';
|
||||
if (flow < 250) return '#d99018';
|
||||
return '#cc4b37';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function latestByStation(measurements) {
|
||||
const latest = new Map();
|
||||
measurements.forEach((item) => {
|
||||
const prior = latest.get(item.station_code);
|
||||
if (!prior || new Date(item.timestamp) > new Date(prior.timestamp)) latest.set(item.station_code, item);
|
||||
});
|
||||
return latest;
|
||||
}
|
||||
|
||||
function formatFlow(value) {
|
||||
return value == null || Number.isNaN(Number(value)) ? 'No data' : `${Number(value).toFixed(1)} m³/s`;
|
||||
}
|
||||
|
||||
function markerSize(flow) {
|
||||
if (flow == null) return 24;
|
||||
return Math.max(24, Math.min(42, 22 + Math.sqrt(Math.max(0, flow)) * 1.05));
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (!window.L) throw new Error('The map library did not load. Check the internet connection.');
|
||||
if (state.map) return;
|
||||
state.map = L.map('station-map', { zoomControl: true, attributionControl: true }).setView([18.78, 98.98], 8);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(state.map);
|
||||
}
|
||||
|
||||
function clearLayers() {
|
||||
state.layers.forEach((layer) => state.map.removeLayer(layer));
|
||||
state.layers = [];
|
||||
state.markers.clear();
|
||||
}
|
||||
|
||||
function buildPopup(station, measurement) {
|
||||
const flow = measurement ? measurement.discharge : null;
|
||||
const level = measurement ? measurement.water_level : null;
|
||||
const time = measurement ? new Date(measurement.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'No reading available';
|
||||
return `<div class="popup">
|
||||
<div class="popup-code">${escapeHtml(station.station_code)}</div>
|
||||
<h3>${escapeHtml(station.english_name)}</h3>
|
||||
<div class="popup-th">${escapeHtml(station.thai_name)}</div>
|
||||
<div class="popup-grid">
|
||||
<div class="popup-metric"><span>Discharge</span><strong>${escapeHtml(formatFlow(flow))}</strong></div>
|
||||
<div class="popup-metric"><span>Water level</span><strong>${level == null ? 'No data' : `${Number(level).toFixed(2)} m`}</strong></div>
|
||||
</div>
|
||||
<div class="popup-time">Reading: ${escapeHtml(time)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRiverNetwork(riverNetwork) {
|
||||
if (!riverNetwork) return;
|
||||
const casing = L.geoJSON(riverNetwork, {
|
||||
style: { color: '#d7f3f5', weight: 6, opacity: .72, lineCap: 'round' }
|
||||
}).addTo(state.map);
|
||||
const flow = L.geoJSON(riverNetwork, {
|
||||
style: { color: '#087da5', weight: 2.5, opacity: .88, dashArray: '5 12', className: 'flow-line' }
|
||||
}).addTo(state.map);
|
||||
casing.bringToBack();
|
||||
flow.bringToBack();
|
||||
state.layers.push(casing, flow);
|
||||
}
|
||||
|
||||
function renderMap(stations, readings, riverNetwork) {
|
||||
clearLayers();
|
||||
renderRiverNetwork(riverNetwork);
|
||||
const mapped = stations.filter((station) => Number.isFinite(station.latitude) && Number.isFinite(station.longitude));
|
||||
const bounds = [];
|
||||
|
||||
mapped.forEach((station) => {
|
||||
const measurement = readings.get(station.station_code);
|
||||
const flow = measurement && measurement.discharge != null ? Number(measurement.discharge) : null;
|
||||
const color = flowColor(flow);
|
||||
const size = markerSize(flow);
|
||||
const icon = L.divIcon({
|
||||
className: 'marker-wrap',
|
||||
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:${size}px">${escapeHtml(station.station_code.replace('P.', ''))}</div>`,
|
||||
iconSize: [size, size], iconAnchor: [size / 2, size / 2], popupAnchor: [0, -size / 2]
|
||||
});
|
||||
const marker = L.marker([station.latitude, station.longitude], { icon, title: `${station.station_code} ${station.english_name}` })
|
||||
.bindPopup(buildPopup(station, measurement)).addTo(state.map);
|
||||
state.layers.push(marker);
|
||||
state.markers.set(station.station_code, marker);
|
||||
bounds.push([station.latitude, station.longitude]);
|
||||
});
|
||||
if (!state.hasFit && bounds.length) {
|
||||
state.map.fitBounds(bounds, { padding: [35, 35], maxZoom: 9 });
|
||||
state.hasFit = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(stationCode) {
|
||||
state.selectedStation = stationCode;
|
||||
$('history-title').textContent = `${stationCode} · PostgreSQL history`;
|
||||
$('history-status').textContent = 'Loading historical measurements…';
|
||||
try {
|
||||
const response = await fetch(`/measurements/history/${encodeURIComponent(stationCode)}?hours=${$('history-range').value}`);
|
||||
if (!response.ok) throw new Error((await response.json()).detail || `HTTP ${response.status}`);
|
||||
const rows = await response.json();
|
||||
if (state.historyChart) state.historyChart.destroy();
|
||||
state.historyChart = new Chart($('history-chart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: rows.map((row) => new Date(row.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit' })),
|
||||
datasets: [
|
||||
{ label: 'Discharge (m³/s)', data: rows.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 },
|
||||
{ label: 'Water level (m)', data: rows.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { flow: { type: 'linear', position: 'left' }, level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false } }, x: { ticks: { maxTicksLimit: 10 } } } }
|
||||
});
|
||||
$('history-status').textContent = rows.length ? `${rows.length} measurements from PostgreSQL` : 'No PostgreSQL measurements in this period';
|
||||
$('history-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} catch (error) {
|
||||
$('history-status').textContent = `History unavailable: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(stations, readings) {
|
||||
const container = $('river-flow');
|
||||
container.replaceChildren();
|
||||
const ordered = [...stations].sort((a, b) => {
|
||||
const af = readings.get(a.station_code)?.discharge;
|
||||
const bf = readings.get(b.station_code)?.discharge;
|
||||
return (bf == null ? -1 : Number(bf)) - (af == null ? -1 : Number(af));
|
||||
});
|
||||
ordered.forEach((station) => {
|
||||
const measurement = readings.get(station.station_code);
|
||||
const flow = measurement?.discharge == null ? null : Number(measurement.discharge);
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button'; row.className = 'station-row';
|
||||
row.innerHTML = `<span class="station-code" style="background:${flowColor(flow)}">${escapeHtml(station.station_code)}</span>
|
||||
<span class="station-name"><strong>${escapeHtml(station.english_name)}</strong><span>${escapeHtml(station.thai_name)}</span></span>
|
||||
<span class="flow-value">${flow == null ? '—' : flow.toFixed(1)}<span>m³/s</span></span>`;
|
||||
row.addEventListener('click', () => {
|
||||
const marker = state.markers.get(station.station_code);
|
||||
if (marker) { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); }
|
||||
loadHistory(station.station_code);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderThaiWaterSensors(sensors, existingCodes) {
|
||||
const container = $('thaiwater-sensors');
|
||||
container.replaceChildren();
|
||||
const additional = sensors.filter((sensor) => !existingCodes.has(sensor.station_code));
|
||||
$('thaiwater-count').textContent = `${additional.length} additional Ping basin stations · water level`;
|
||||
additional.forEach((sensor) => {
|
||||
const percent = sensor.bank_percent == null ? null : Number(sensor.bank_percent);
|
||||
const color = percent == null ? '#7b8f94' : percent >= 100 ? '#cc4b37' : percent >= 80 ? '#d99018' : '#6c73b8';
|
||||
const icon = L.divIcon({
|
||||
className: 'marker-wrap',
|
||||
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:22px">+</div>`,
|
||||
iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -11]
|
||||
});
|
||||
const level = sensor.water_level_msl == null ? 'No data' : `${Number(sensor.water_level_msl).toFixed(2)} m MSL`;
|
||||
const bank = sensor.distance_to_bank == null ? 'Unknown' : `${Number(sensor.distance_to_bank).toFixed(2)} m below bank`;
|
||||
const marker = L.marker([sensor.latitude, sensor.longitude], { icon, title: `${sensor.station_code} ${sensor.station_name}` })
|
||||
.bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(sensor.station_code)} · ThaiWater</div><h3>${escapeHtml(sensor.station_name)}</h3><div class="popup-th">${escapeHtml(sensor.river_name || 'Ping basin')} · ${escapeHtml(sensor.agency || '')}</div><div class="popup-grid"><div class="popup-metric"><span>Water level</span><strong>${escapeHtml(level)}</strong></div><div class="popup-metric"><span>Bank status</span><strong>${escapeHtml(bank)}</strong></div></div></div>`)
|
||||
.addTo(state.map);
|
||||
state.layers.push(marker);
|
||||
state.markers.set(`thaiwater:${sensor.station_code}`, marker);
|
||||
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button'; row.className = 'station-row';
|
||||
row.innerHTML = `<span class="station-code" style="background:${color}">${escapeHtml(sensor.station_code)}</span><span class="station-name"><strong>${escapeHtml(sensor.station_name)}</strong><span>${escapeHtml(sensor.river_name || 'Ping basin')} · ThaiWater</span></span><span class="flow-value">${percent == null ? '—' : percent.toFixed(0) + '%'}<span>bank level</span></span>`;
|
||||
row.addEventListener('click', () => { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); });
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(stations, readings) {
|
||||
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
|
||||
const flows = current.filter((m) => m.discharge != null).map((m) => ({ code: m.station_code, value: Number(m.discharge) }));
|
||||
const total = flows.reduce((sum, item) => sum + item.value, 0);
|
||||
const peak = flows.length ? flows.reduce((max, item) => item.value > max.value ? item : max) : null;
|
||||
const timestamps = current.map((m) => new Date(m.timestamp)).filter((date) => !Number.isNaN(date.getTime()));
|
||||
const latest = timestamps.length ? new Date(Math.max(...timestamps.map((date) => date.getTime()))) : null;
|
||||
$('station-count').textContent = `${current.length} / ${stations.length}`;
|
||||
$('total-flow').textContent = flows.length ? total.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '—';
|
||||
$('peak-flow').textContent = peak ? peak.value.toFixed(1) : '—';
|
||||
$('peak-station').textContent = peak ? `${peak.code} · m³/s` : 'No discharge reported';
|
||||
$('last-updated').textContent = latest ? latest.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—';
|
||||
if (latest) {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
|
||||
$('data-age').textContent = `${latest.toLocaleDateString([], { day: 'numeric', month: 'short' })} · ${minutes} min ago`;
|
||||
} else $('data-age').textContent = 'No timestamp available';
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const refresh = $('refresh-button');
|
||||
refresh.disabled = true;
|
||||
$('loading').style.display = 'grid';
|
||||
$('error').style.display = 'none';
|
||||
try {
|
||||
initMap();
|
||||
const [stationResponse, measurementResponse, riverResponse, thaiWaterResponse] = await Promise.all([
|
||||
fetch('/stations'),
|
||||
fetch('/measurements/latest?limit=500'),
|
||||
fetch('/static/ping-river-network.geojson'),
|
||||
fetch('/sensors/thaiwater')
|
||||
]);
|
||||
if (!stationResponse.ok || !measurementResponse.ok || !riverResponse.ok) {
|
||||
throw new Error(`API returned ${stationResponse.status}/${measurementResponse.status}/${riverResponse.status}`);
|
||||
}
|
||||
const stations = await stationResponse.json();
|
||||
const measurements = await measurementResponse.json();
|
||||
const riverNetwork = await riverResponse.json();
|
||||
const thaiWaterSensors = thaiWaterResponse.ok ? await thaiWaterResponse.json() : [];
|
||||
const readings = latestByStation(measurements);
|
||||
renderMap(stations, readings, riverNetwork);
|
||||
renderList(stations, readings);
|
||||
renderThaiWaterSensors(thaiWaterSensors, new Set(stations.map((station) => station.station_code)));
|
||||
renderSummary(stations, readings);
|
||||
$('loading').style.display = 'none';
|
||||
} catch (error) {
|
||||
$('loading').style.display = 'none';
|
||||
$('error').style.display = 'grid';
|
||||
$('error-message').textContent = error.message;
|
||||
console.error('Dashboard load failed:', error);
|
||||
} finally {
|
||||
refresh.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$('refresh-button').addEventListener('click', loadDashboard);
|
||||
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
|
||||
loadDashboard();
|
||||
window.setInterval(loadDashboard, 5 * 60 * 1000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
||||
"""Client for ThaiWater's public water-level sensor feed."""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class ThaiWaterClient:
|
||||
API_URL = "https://twa-api-public.thaiwater.net/v2/waterlevel"
|
||||
|
||||
def __init__(self, session=None, api_key: Optional[str] = None, timeout: int = 30):
|
||||
self.session = session or requests.Session()
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def fetch_ping_sensors(self) -> List[Dict]:
|
||||
if not self.api_key:
|
||||
raise RuntimeError("THAIWATER_API_KEY is not configured")
|
||||
|
||||
response = self.session.get(
|
||||
self.API_URL,
|
||||
headers={"Accept-Language": "en", "x-api-key": self.api_key},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return self._parse_ping_features(response.json())
|
||||
|
||||
@staticmethod
|
||||
def _parse_ping_features(payload: Dict) -> List[Dict]:
|
||||
sensors = []
|
||||
for collection in payload.get("data", {}).values():
|
||||
for feature in collection.get("features", []):
|
||||
properties = feature.get("properties") or {}
|
||||
basin = properties.get("basin") or {}
|
||||
if basin.get("basin") != "Ping":
|
||||
continue
|
||||
|
||||
geometry = feature.get("geometry") or {}
|
||||
coordinates = geometry.get("coordinates") or []
|
||||
if len(coordinates) < 2:
|
||||
continue
|
||||
|
||||
station = properties.get("station") or {}
|
||||
station_code = station.get("stationCode", "")
|
||||
sensors.append(
|
||||
{
|
||||
"id": f"thaiwater:{properties.get('id')}",
|
||||
"station_code": station_code.split("-", 1)[-1],
|
||||
"station_name": station.get("station"),
|
||||
"latitude": coordinates[1],
|
||||
"longitude": coordinates[0],
|
||||
"timestamp": properties.get("waterlevelDatetime"),
|
||||
"water_level_msl": properties.get("waterlevelMsl"),
|
||||
"bank_percent": properties.get("storagePercent"),
|
||||
"distance_to_bank": properties.get("diffWlBank"),
|
||||
"river_name": properties.get("riverName"),
|
||||
"agency": (properties.get("agency") or {}).get("agencyShort"),
|
||||
"source": "ThaiWater",
|
||||
}
|
||||
)
|
||||
return sensors
|
||||
+50
-1
@@ -9,14 +9,17 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||
import requests
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import Config
|
||||
from .health_check import APIHealthCheck, DatabaseHealthCheck, HealthCheckManager, MemoryHealthCheck
|
||||
from .logging_config import get_logger, setup_logging
|
||||
from .metrics import get_metrics_collector, increment_counter, set_gauge
|
||||
from .postgres_history import PostgresHistory
|
||||
from .schemas import (
|
||||
HealthResponse,
|
||||
MeasurementResponse,
|
||||
@@ -26,6 +29,7 @@ from .schemas import (
|
||||
StationResponse,
|
||||
StationUpdateModel,
|
||||
)
|
||||
from .thaiwater import ThaiWaterClient
|
||||
from .water_scraper_v3 import EnhancedWaterMonitorScraper
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -108,6 +112,7 @@ app = FastAPI(
|
||||
version="3.1.3",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=os.path.dirname(_DASHBOARD_HTML_PATH)), name="static")
|
||||
|
||||
# Add CORS middleware.
|
||||
# Origins come from CORS_ALLOW_ORIGINS (comma-separated). When none are configured
|
||||
@@ -419,6 +424,50 @@ def _to_measurement_response(measurement: Dict[str, Any]) -> MeasurementResponse
|
||||
)
|
||||
|
||||
|
||||
@app.get("/sensors/thaiwater")
|
||||
async def get_thaiwater_sensors():
|
||||
"""Get current ThaiWater water-level sensors in the Ping basin."""
|
||||
increment_counter("api_requests", labels={"endpoint": "thaiwater_sensors"})
|
||||
try:
|
||||
client = ThaiWaterClient(
|
||||
api_key=Config.THAIWATER_API_KEY,
|
||||
timeout=Config.REQUEST_TIMEOUT,
|
||||
)
|
||||
return await asyncio.to_thread(client.fetch_ping_sensors)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error))
|
||||
except requests.RequestException as error:
|
||||
logger.error(f"Error fetching ThaiWater sensors: {error}")
|
||||
raise HTTPException(status_code=502, detail="ThaiWater API unavailable")
|
||||
|
||||
|
||||
@app.get("/measurements/history/{station_code}")
|
||||
async def get_postgres_history(
|
||||
station_code: str,
|
||||
hours: int = Query(168, ge=1, le=24 * 365),
|
||||
limit: int = Query(2000, ge=1, le=5000),
|
||||
):
|
||||
"""Get historical measurements for a station from PostgreSQL."""
|
||||
try:
|
||||
db_config = Config.get_database_config()
|
||||
if db_config["type"] != "postgresql":
|
||||
raise RuntimeError("PostgreSQL is not configured")
|
||||
history = PostgresHistory(db_config["connection_string"])
|
||||
end_time = datetime.now()
|
||||
return await asyncio.to_thread(
|
||||
history.station_history,
|
||||
station_code,
|
||||
end_time - timedelta(hours=hours),
|
||||
end_time,
|
||||
limit,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error))
|
||||
except Exception as error:
|
||||
logger.error(f"Error fetching PostgreSQL history: {error}")
|
||||
raise HTTPException(status_code=502, detail="PostgreSQL history unavailable")
|
||||
|
||||
|
||||
@app.get("/measurements/latest", response_model=List[MeasurementResponse])
|
||||
async def get_latest_measurements(limit: int = 100):
|
||||
"""Get latest measurements from all stations"""
|
||||
|
||||
Reference in New Issue
Block a user