feat: database stats on the dashboard via GET /api/stats
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 23s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 14s
Documentation / Validate Documentation (push) Failing after 8s
Documentation / Generate API Documentation (push) Successful in 9s
Documentation / Build Sphinx Documentation (push) Successful in 17s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s

New SQLAdapter.get_database_stats() aggregates totals, station count,
date range, and hourly-slot coverage in one query per dialect. The
endpoint follows the existing 503-guard/to_thread/TTL-cache pattern;
the dashboard gains a five-tile stats strip on the existing refresh
cadence. Coverage denominator is hour-truncated so off-hour endpoints
cannot push it past 100%; MySQL slot expression avoids % characters
that would break under pyformat bind interpolation.
This commit is contained in:
2026-08-10 23:27:36 +07:00
parent 5e62ea529d
commit 7befc82ff5
3 changed files with 140 additions and 0 deletions
+70
View File
@@ -56,6 +56,14 @@ class DatabaseAdapter(ABC):
"""
return None
def get_database_stats(self) -> Optional[Dict]:
"""Summary statistics over stored measurements: total count, distinct
stations, first/last timestamp, and hourly-slot coverage.
Returns None when the backend has no data or does not support the query.
"""
return None
# InfluxDB Adapter
class InfluxDBAdapter(DatabaseAdapter):
@@ -764,6 +772,68 @@ class SQLAdapter(DatabaseAdapter):
)
return None
def get_database_stats(self) -> Optional[Dict]:
if not self.engine:
return None
try:
from sqlalchemy import text
if self.db_type == "sqlite":
slot_expr = "strftime('%Y-%m-%d %H', timestamp)"
elif self.db_type == "postgresql":
slot_expr = "TO_CHAR(timestamp, 'YYYY-MM-DD HH24')"
else: # MySQL
# %-free expression: a bare % inside text() breaks as soon as the
# query gains a bind parameter (pyformat interpolation)
slot_expr = "CONCAT(DATE(timestamp), ' ', HOUR(timestamp))"
query = f"""
SELECT COUNT(*),
COUNT(DISTINCT station_id),
MIN(timestamp),
MAX(timestamp),
COUNT(DISTINCT {slot_expr})
FROM water_measurements
"""
with self.engine.connect() as conn:
row = conn.execute(text(query)).fetchone()
if not row or not row[0]:
return None
def to_datetime(value):
if isinstance(value, datetime.datetime):
return value
return datetime.datetime.fromisoformat(str(value)[:19])
first_ts = to_datetime(row[2])
last_ts = to_datetime(row[3])
# Truncate to the hour before differencing so the slot count matches
# the DISTINCT day-hour slots and coverage cannot exceed 100%
first_slot = first_ts.replace(minute=0, second=0, microsecond=0)
last_slot = last_ts.replace(minute=0, second=0, microsecond=0)
expected_hours = (
int((last_slot - first_slot).total_seconds() // 3600) + 1
)
recorded_hours = int(row[4])
coverage_percent = round(100.0 * recorded_hours / expected_hours, 1)
return {
"total_measurements": int(row[0]),
"station_count": int(row[1]),
"first_timestamp": first_ts,
"last_timestamp": last_ts,
"recorded_hours": recorded_hours,
"expected_hours": expected_hours,
"coverage_percent": coverage_percent,
}
except Exception as e:
logging.error(f"Error querying {self.db_type.upper()} stats: {e}")
return None
# VictoriaMetrics Adapter (using Prometheus format)
class VictoriaMetricsAdapter(DatabaseAdapter):