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
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user