Skip to content

feat: Add historical time slider with live/historical mode switching and Grafana sync - #61

Merged
cchwala merged 23 commits into
mainfrom
feat/time-slider
Jul 27, 2026
Merged

feat: Add historical time slider with live/historical mode switching and Grafana sync#61
cchwala merged 23 commits into
mainfrom
feat/time-slider

Conversation

@cchwala

@cchwala cchwala commented Jul 25, 2026

Copy link
Copy Markdown
Member

Summary

Implements a fully functional historical time slider for the GMDI realtime dashboard, allowing users to explore past data quality states with synchronized map coloring and Grafana chart updates.

Key Features

Time Slider (/realtime)

  • Historical navigation: Hourly slider spanning full data history (supports months/years of data)
  • Live mode: "● Live" button resets to current hour with auto-refresh every 30s
  • Instant updates: Map colors and Grafana time range update on every slider tick during drag
  • Smart request cancellation: Aborts in-flight requests when slider moves faster than API responds

Grafana Integration

  • Smooth sync: Uses replaceState + popstate for in-place time range updates (no full reload)
  • Live reset: Properly reloads Grafana when returning to live mode to restore relative time (now-1h)
  • Coloring window marker: Grey shaded region overlay shows the 1-hour aggregation window used for "RSL Std Dev" and "Last RSL" coloring options
  • Annotation API: New /api/coloring-annotation endpoint creates stored Grafana annotations server-side (bypasses Viewer role restrictions)

Data Quality Improvements

  • Default view: Opens with "RSL Std Dev (1h)" coloring (most diagnostically useful)
  • NaN handling: Database function filters NaN values to prevent propagation through aggregates
  • Provisional snapshots: Parser writes current-hour snapshots every 60s for up-to-date slider data

Bug Fixes

  1. Live button not updating colors - Removed premature updateMapColors() call before async stats fetch completed
  2. Live button not resetting Grafana - Changed from replaceState to location.replace() for proper relative time reset
  3. Grey lines for recent hours - Fixed write_provisional_snapshot() attempting to refresh cml_stats as CAGG (it's a regular table)
  4. JavaScript ReferenceErrors - Added missing sliderDebounce and sliderFetchCtrl declarations
  5. Browser caching - Added no-cache headers to /realtime endpoint

Technical Changes

Backend (webserver/main.py)

  • New /api/coloring-annotation POST/DELETE endpoint (creates Grafana annotations with admin credentials)
  • New /api/coloring-annotation-cleanup endpoint (removes stale annotations)
  • No-cache headers on /realtime route

Frontend (webserver/templates/realtime.html)

  • Refactored live button handler to properly sequence: fetch → refresh timer → Grafana reset → annotation removal
  • Added AbortController for canceling in-flight slider requests
  • Debug console logging for troubleshooting
  • Default coloring option changed to std_dev

Parser (parser/db_writer.py)

  • Removed invalid refresh_continuous_aggregate('cml_stats', ...) call from write_provisional_snapshot()
  • cml_stats is a regular table updated by update_cml_stats(), not a continuous aggregate

Database (database/init.sql)

  • materialize_cml_stats_snapshot() uses NULLIF(val, 'NaN'::float) to prevent NaN poisoning aggregates

Testing

New Tests

  • webserver/tests/test_coloring_annotation_api.py (5 tests)

    • Create/update/delete annotation endpoints
    • Error handling for missing parameters
    • Cleanup endpoint removes all tagged annotations
  • parser/tests/test_stats_snapshot.py (updated + 1 new test)

    • Fixed existing tests to expect CAGG refresh + materialize (2 execute calls)
    • Added test_write_provisional_snapshot_does_not_refresh_cml_stats

Test Results

99 passed, 0 failed

Deployment Notes

  1. Database migration: Existing materialize_cml_stats_snapshot() function should be updated with NaN filtering (already in init.sql)
  2. Parser restart: Required to deploy db_writer.py fix for provisional snapshots
  3. Grafana: Annotation appears automatically via API; no manual dashboard changes needed

Screenshots

With time slider at an archive time stamp
Bildschirmfoto 2026-07-27 um 21 07 56

In "live" mode showing most recent data
Bildschirmfoto 2026-07-27 um 21 09 14

@cchwala cchwala changed the title Feat/time slider feat: Historical time slider for realtime CML map Jul 25, 2026
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.93985% with 64 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.39%. Comparing base (ff803fd) to head (b8dc6a8).

Files with missing lines Patch % Lines
parser/backfill_stats_history.py 0.00% 34 Missing ⚠️
parser/db_writer.py 66.03% 18 Missing ⚠️
webserver/main.py 87.14% 9 Missing ⚠️
parser/entrypoints/sftp_push.py 82.35% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #61      +/-   ##
==========================================
- Coverage   86.10%   85.39%   -0.71%     
==========================================
  Files          39       41       +2     
  Lines        3418     3678     +260     
==========================================
+ Hits         2943     3141     +198     
- Misses        475      537      +62     
Flag Coverage Δ
mno_simulator 86.12% <ø> (ø)
parser 90.14% <71.93%> (-1.86%) ⬇️
scripts 74.21% <ø> (ø)
webserver 75.33% <87.14%> (+1.55%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cchwala
cchwala force-pushed the feat/time-slider branch from 3d85638 to 1748b4b Compare July 25, 2026 17:56
cchwala added 7 commits July 25, 2026 23:05
Add new hypertable to store hourly snapshots of CML statistics, enabling
historical queries without scanning raw data.

Changes:
- Create cml_stats_history table with compression policy (7 days)
- Add materialize_cml_stats_snapshot() function to write definitive
  snapshots from cml_data_1h aggregates
- Add get_cml_stats_at() function for efficient historical lookups
- Grant template comments for per-user isolation via generate_config.py

Key features:
- Idempotent upserts via ON CONFLICT (snapshot_time, cml_id, user_id)
- Supports provisional (current partial hour) and definitive rows
- Hour-truncated snapshot times for consistent querying
- Compression enabled for snapshots older than 7 days
Implement background jobs to populate cml_stats_history with hourly
snapshots, enabling fast historical queries without raw data scans.

Changes:
- Add DBWriter.write_stats_snapshot(at_time) - materializes definitive
  snapshot from cml_data_1h for completed hours
- Add DBWriter.write_provisional_snapshot() - copies live cml_stats
  every 60s for current partial hour (is_provisional=TRUE)
- Wire both methods into stats background thread in sftp_push.py
- Add backfill_stats_history.py script for populating historical data

Architecture:
- Provisional snapshots: written every 60s, at most 60s stale
- Definitive snapshots: written when hour turns, sourced from aggregates
- ON CONFLICT DO UPDATE ensures idempotent re-materialization
- Backfill script processes all available hours from cml_data_1h
Extend API to support historical queries for the time slider feature.

Changes:
- Extend /api/cml-stats with optional ?at= parameter for historical lookups
  - Parses ISO 8601 timestamp, queries get_cml_stats_at() function
  - Returns identical response shape with added is_provisional flag
  - Invalid timestamps return HTTP 400
- Add /api/cml-stats-time-range endpoint
  - Returns min(snapshot_time) and max (current hour) from cml_stats_history
  - Enables frontend to configure slider min/max bounds
- Update generate_config.py to grant permissions for new table/function

Key properties:
- Live mode unchanged when ?at= omitted
- Historical reads use index lookups, no raw data scans
- Response includes is_provisional for current partial hour
Implement interactive time slider for historical data exploration with
Grafana dashboard synchronization.

Changes:
- Add time slider section to Leaflet ColorControl widget
  - Range input with 1-hour steps, enabled after loading time range
  - Live button toggles between live/history modes
  - Visual display shows selected time or 'Live'
  - Debounced input (300ms) to avoid excessive API calls
- Implement fetchAndApplyStats(atIso) function
  - Fetches stats with optional ?at= parameter
  - Displays '(partial)' label for provisional snapshots
  - Updates map path colors based on historical stats
- Add syncGrafanaTime(epochSec) function
  - Syncs iframe time range via contentWindow.location
  - Live mode: relative 'now-1h' to 'now'
  - Historical mode: ±30 min window around selection
  - Sets var-marker_time variable for annotation pin
- Update Grafana dashboard cml-realtime.json
  - Add hidden marker_time template variable (textbox, epoch ms)
  - Add 'Selected time' annotation pinned to marker_time
- Auto-refresh in live mode (30s), paused when viewing history

User experience:
- Slider drag shows preview timestamp immediately
- After 300ms debounce, fetches historical stats and syncs Grafana
- Clicking 'Live' button restores auto-refresh and current time
- Partial hours clearly labeled to indicate incomplete data
Test coverage:
- parser/tests/test_stats_snapshot.py: Tests for DBWriter snapshot methods
  - write_stats_snapshot() materialization from aggregates
  - write_provisional_snapshot() copying live stats
  - Error handling and rollback behavior
  - Timezone handling for naive datetimes
- webserver/tests/test_time_slider_api.py: API endpoint tests
  - Historical queries with ?at= parameter
  - Invalid timestamp handling (HTTP 400)
  - Time range endpoint responses
  - Empty history scenarios

Documentation:
- docs/plan-time-slider-2026-07-24.md: Complete implementation plan
  - Architecture overview and data flow
  - Database schema and functions
  - Parser background job design
  - Webserver API extensions
  - Frontend UI components
  - Grafana dashboard integration
  - Testing strategy and deployment notes
Upstream/main only has migrations up to 009. The deploy fork's ifu/main
branch contains additional migrations 010-016 that are either
deployment-specific (not intended for upstream) or generic performance
improvements not yet submitted as upstream PRs. Neither set has SQL
dependencies on cml_stats_history, so the correct next number for this
feature in the upstream sequence is 010.

Updated all migration number references in docs and backfill script.
perf/db-improvements-batch now claims migration numbers 010-013
upstream, so cml_stats_history must move to 014 to avoid a collision
once both PRs are merged.
@cchwala
cchwala force-pushed the feat/time-slider branch from 9ae331f to 3de23a8 Compare July 25, 2026 21:08
cchwala added 14 commits July 25, 2026 23:21
…bugs

migration 014 adds record_count to the cml_data_1h continuous
aggregate, a column materialize_cml_stats_snapshot() (015, renumbered
from 014) has always depended on but which never existed anywhere
(neither upstream nor in the original plan's schema).

While testing against a throwaway DB, found and fixed two further
bugs in materialize_cml_stats_snapshot() that only surfaced once
record_count existed:
- the 1h LATERAL subquery never computed rsl_count (valid-record
  count), unlike the 6h subquery
- the outer query wrapped the 6h LATERAL subquery's already-aggregated
  columns in a second layer of SUM/AVG with no GROUP BY, which is
  invalid SQL (the LATERAL join already produces one aggregated row
  per cml_id)

Verified end-to-end against a throwaway container: inserted sample
cml_data, refreshed cml_data_1h, called
materialize_cml_stats_snapshot() and get_cml_stats_at(), confirmed
correct completeness percentages and record counts.
A fresh DB (docker compose up with an empty volume) only runs
init.sql, never the migrations/ files. Folds in migrations 014
(record_count on cml_data_1h) and 015 (cml_stats_history table +
materialize_cml_stats_snapshot/get_cml_stats_at) so a fresh install
matches a fully-migrated one and the time slider feature works
out of the box.

Verified by initializing a throwaway container from a clean volume,
inserting sample cml_data, and confirming materialize_cml_stats_snapshot()
and get_cml_stats_at() return correct results.
…ml-stats

The invalid-timestamp check ran inside user_db_scope(), so any DB/user
error (e.g. Unknown user_id) raised first would return 200 with a
fallback instead of the intended 400. Validate 'at' before touching
the DB so a malformed timestamp always yields 400 regardless of DB
state.

Fixes CI failure in test_api_cml_stats_invalid_at_parameter.
- database/init.sql: Apply Row Level Security properly in get_cml_stats_at()
  by filtering cml_metadata on user_id before joining. This ensures users
  only see their own CML data in historical queries.

- parser/db_writer.py: Add explicit refresh_continuous_aggregate() call for
  cml_data_1h before materializing snapshots. This ensures the 1h stats
  subquery finds current data when building hourly snapshots.
- webserver/main.py:
  - Unify column ordering between live and historical stats queries
  - Add mean_rsl_1h column to live query (previously missing)
  - Standardize response shape with consistent is_provisional field
  - Add /api/coloring-annotation endpoint (POST/DELETE) for managing
    Grafana region annotations that show the 1h coloring window
  - Add /api/coloring-annotation-cleanup endpoint for removing stale
    annotations from previous sessions

These annotation endpoints use admin credentials internally so Viewer-role
users can drive annotations. Rapid slider dragging is handled gracefully
with AbortController-style cleanup of orphaned annotations.
- webserver/templates/realtime.html:
  - Change default coloring option from 'completeness' to 'std_dev'
    (more relevant for real-time RSL stability monitoring)
  - Improve slider event handling: 'input' event for live updates during
    dragging, 'change' event to commit selection and stop live refresh
  - Add AbortController to cancel in-flight requests during rapid slider
    movement, preventing race conditions
  - Enhance syncGrafanaTime(): preserve user's zoom level by reading
    current from/to params, use replaceState+popstate for seamless in-place
    updates instead of full iframe reloads
  - Add updateColoringAnnotation(): creates gray shaded region annotation
    showing the 6h coloring window used for map coloring
  - Add cleanupColoringAnnotations(): removes stale annotations on page load

- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grafana/provisio- grles- grafana/provisio- grafana/pret defa- grafana/provisio- grafana/provisio- grafana/provisio- grafanze marker_time variable to '0' as placeholder
Changed the default halfWindow from ±30 min (1h total) to ±3h (6h total)
in syncGrafanaTime(). This provides better temporal context when the user
first moves the time slider, while still preserving manual zoom adjustments
for subsequent interactions.
…-red progression

- Changed from green-yellow-orange-red to blue-yellow-orange-red
- Fixed thresholds: ≤2 blue, 2-4 yellow, 4-6 orange, 6-8 deep orange, 8+ red
- Colorblind-friendly: removes green-red confusion while maintaining intuitive meaning
- Updated missing data color to slate gray (#94a3b8) for consistency
…al data

- webserver/templates/realtime.html: Reset time slider to max position when
  clicking Live button, ensuring visual feedback matches live mode state

- parser/db_writer.py: Add CALL refresh_continuous_aggregate('cml_stats', ...)
  in write_provisional_snapshot() to ensure the 6-hour rolling view is
  refreshed every 60 seconds alongside provisional snapshot writes

This ensures both live view and historical slider have access to fresh stats
data, fixing grey colors for recent hours.
When clicking Live, manually dispatch input event after resetting slider
position to ensure map colors and Grafana zoom update immediately.
Ensure map colors are refreshed when clicking Live button by calling
updateMapColors() in the promise chain after fetchAndApplyStats().
- Fixed JavaScript ReferenceError (sliderDebounce, sliderFetchCtrl not defined)
- Removed premature updateMapColors() call before stats fetch completed
- Added debug console.log statements for troubleshooting
- Added no-cache headers to /realtime endpoint to prevent browser caching
- Live button now correctly:
  * Fetches live stats and applies them to map lines via applyStatsToLine()
  * Resets Grafana to 'now-1h/now' mode with full reload
  * Removes coloring annotation
  * Starts auto-refresh timer
…napshot()

cml_stats is a regular table updated by update_cml_stats(), not a continuous aggregate.
Attempting to refresh it caused 'relation is not a continuous aggregate' errors,
preventing provisional snapshots from being written every 60 seconds.

This fix allows the parser to continuously populate cml_stats_history with current-hour
data, eliminating the 3-4 hour gap of grey/NULL values on the map.
…ng annotation API tests

- Fixed test_write_stats_snapshot_success to expect 2 execute() calls (CAGG refresh + materialize)
- Fixed test_write_stats_snapshot_rollback_on_error to only fail on materialize step
- Added test_write_provisional_snapshot_does_not_refresh_cml_stats to verify no invalid CAGG refresh
- Added new test_coloring_annotation_api.py with 5 tests for /api/coloring-annotation endpoints
@cchwala cchwala changed the title feat: Historical time slider for realtime CML map feat: Add historical time slider with live/historical mode switching and Grafana sync Jul 27, 2026
cchwala added 2 commits July 27, 2026 21:05
- test_api_cml_stats: Updated mock data to 11 columns (added mean_rsl_1h, is_provisional)
- test_create_annotation_success: Use call_args.kwargs instead of deprecated indexing
- test_cleanup_removes_all_tagged_annotations: Fix params assertion to use .get() method
@cchwala
cchwala merged commit f805562 into main Jul 27, 2026
6 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant