From 85d02c81a5a96204f019ee0e23f32ccf36b71c07 Mon Sep 17 00:00:00 2001 From: David Kiania Date: Sun, 19 Apr 2026 13:44:18 +0300 Subject: [PATCH] feat: Daily Operations dashboard + tracksolid analytics views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second Grafana dashboard focused on daily operational KPIs and live dispatch, keeping the NOC Live dashboard untouched. - grafana/provisioning/dashboards-json/daily_operations_dashboard.json New dashboard covering §7 Blueprint Panels 3-8 and the §4 dispatch lens: freshness banner, today-at-a-glance stat row, active vehicles map, currently-idle table, vehicles-not-moved-today, per-vehicle daily KPI roll-up, driver behaviour leaderboard, distance trend, alarm frequency, idle cost MTD, utilisation heatmap, SLA row (collapsed, data-gated). - 07_analytics_views.sql Nine views in tracksolid.* wrapping the BA-file [DASHBOARD]-tagged queries. Each view carries COMMENT ON VIEW with its spec section. SELECT granted to grafana_ro. Smoke-tested against live DB. - run_migrations.py Register 06 and 07 in MIGRATIONS list with idempotent seed checks so future fresh deploys apply them correctly. - CLAUDE.md Retire the tracksolid_2 schema references (schema no longer exists); §9 Fleet State dated 2026-04-19 with correct pipeline status (running, 875 runs/24h, 0 failures) and accurate position_history row counts (hypertable stats don't show in pg_stat_user_tables). - docs/superpowers/specs/2026-04-19-daily-operations-dashboard-design.md Design spec covering architecture, views, panel layout, deployment, rollback, and known data gaps. --- 07_analytics_views.sql | 348 ++++++++++ CLAUDE.md | 72 ++- ...04-19-daily-operations-dashboard-design.md | 140 +++++ .../daily_operations_dashboard.json | 594 ++++++++++++++++++ run_migrations.py | 16 +- 5 files changed, 1140 insertions(+), 30 deletions(-) create mode 100644 07_analytics_views.sql create mode 100644 docs/superpowers/specs/2026-04-19-daily-operations-dashboard-design.md create mode 100644 grafana/provisioning/dashboards-json/daily_operations_dashboard.json diff --git a/07_analytics_views.sql b/07_analytics_views.sql new file mode 100644 index 0000000..7baa28c --- /dev/null +++ b/07_analytics_views.sql @@ -0,0 +1,348 @@ +-- 07_analytics_views.sql +-- Analytics views backing the Daily Operations dashboard. +-- Each view wraps a query block from 01_BusinessAnalytics.md; the section +-- is recorded in COMMENT ON VIEW so debuggers can trace any panel back to +-- its spec with a single \d+ command. +-- +-- All views are regular (not materialised). v_driver_aggregates_daily is +-- the only one with performance risk as position_history grows; convert +-- it to a TimescaleDB continuous aggregate once the hypertable exceeds +-- ~100k rows. + +BEGIN; + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_fleet_today +-- One row per device with today's operational roll-up. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_fleet_today AS +WITH today AS ( + SELECT (NOW() AT TIME ZONE 'Africa/Nairobi')::date AS d +), +trips_today AS ( + SELECT + t.imei, + COUNT(*) AS trips, + SUM(t.distance_km) AS km, + SUM(t.driving_time_s)::numeric / 3600 AS drive_hours, + SUM(t.idle_time_s)::numeric / 3600 AS idle_hours, + MIN(t.start_time AT TIME ZONE 'Africa/Nairobi')::time AS first_departure, + MAX(COALESCE(t.end_time, t.start_time) AT TIME ZONE 'Africa/Nairobi')::time AS last_return + FROM tracksolid.trips t + CROSS JOIN today + WHERE (t.start_time AT TIME ZONE 'Africa/Nairobi')::date = today.d + GROUP BY t.imei +), +alarms_today AS ( + SELECT + a.imei, + COUNT(*) AS alarm_count + FROM tracksolid.alarms a + CROSS JOIN today + WHERE (a.alarm_time AT TIME ZONE 'Africa/Nairobi')::date = today.d + GROUP BY a.imei +) +SELECT + d.imei, + d.driver_name, + d.vehicle_number, + d.vehicle_name, + COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city, + d.enabled_flag, + COALESCE(tt.km, 0) AS km_today, + COALESCE(tt.trips, 0) AS trips_today, + COALESCE(tt.drive_hours, 0) AS drive_hours, + COALESCE(tt.idle_hours, 0) AS idle_hours, + tt.first_departure, + tt.last_return, + COALESCE(at.alarm_count, 0) AS alarms_today, + lp.gps_time AT TIME ZONE 'Africa/Nairobi' AS last_fix, + lp.speed AS last_speed, + (tt.imei IS NULL) AS did_not_move +FROM tracksolid.devices d +LEFT JOIN trips_today tt ON tt.imei = d.imei +LEFT JOIN alarms_today at ON at.imei = d.imei +LEFT JOIN tracksolid.live_positions lp ON lp.imei = d.imei; + +COMMENT ON VIEW tracksolid.v_fleet_today IS + '01_BusinessAnalytics.md §9 Fleet Readiness Scorecard. One row per device with today''s roll-up.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_vehicles_not_moved_today +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_vehicles_not_moved_today AS +SELECT + d.imei, + d.vehicle_name, + d.vehicle_number, + d.driver_name, + COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city, + lp.gps_time AT TIME ZONE 'Africa/Nairobi' AS last_seen, + lp.speed +FROM tracksolid.devices d +LEFT JOIN tracksolid.live_positions lp ON lp.imei = d.imei +LEFT JOIN tracksolid.trips t + ON t.imei = d.imei + AND (t.start_time AT TIME ZONE 'Africa/Nairobi')::date + = (NOW() AT TIME ZONE 'Africa/Nairobi')::date +WHERE d.enabled_flag = 1 + AND t.imei IS NULL; + +COMMENT ON VIEW tracksolid.v_vehicles_not_moved_today IS + '01_BusinessAnalytics.md §2.3. Enabled vehicles with zero trips today.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_active_dispatch_map +-- Every enabled device with current position, colour-coded status for geomap. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_active_dispatch_map AS +SELECT + d.imei, + d.vehicle_number, + d.vehicle_name, + d.driver_name, + d.driver_phone, + COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city, + lp.lat, + lp.lng, + lp.speed, + lp.direction, + lp.acc_status, + lp.gps_time AT TIME ZONE 'Africa/Nairobi' AS last_fix, + CASE + WHEN lp.gps_time IS NULL THEN 'never_reported' + WHEN lp.gps_time < NOW() - INTERVAL '10 minutes' THEN 'stale' + WHEN COALESCE(lp.speed, 0) > 5 THEN 'moving' + WHEN lp.acc_status = '1' THEN 'idle_ignition_on' + ELSE 'parked' + END AS status +FROM tracksolid.devices d +LEFT JOIN tracksolid.live_positions lp ON lp.imei = d.imei +WHERE d.enabled_flag = 1; + +COMMENT ON VIEW tracksolid.v_active_dispatch_map IS + '01_BusinessAnalytics.md §4.3 All Active Vehicles Map. Geomap source.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_currently_idle +-- Engine on, speed ~0, fresh fix. "Currently burning fuel while parked." +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_currently_idle AS +SELECT + d.imei, + d.vehicle_number, + d.driver_name, + COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city, + lp.lat, + lp.lng, + lp.gps_time AT TIME ZONE 'Africa/Nairobi' AS since, + EXTRACT(EPOCH FROM (NOW() - lp.gps_time))::int AS idle_seconds +FROM tracksolid.devices d +JOIN tracksolid.live_positions lp ON lp.imei = d.imei +WHERE d.enabled_flag = 1 + AND lp.acc_status = '1' + AND COALESCE(lp.speed, 0) < 2 + AND lp.gps_time > NOW() - INTERVAL '15 minutes'; + +COMMENT ON VIEW tracksolid.v_currently_idle IS + '01_BusinessAnalytics.md §2.2 idle lens. Engine on, speed <2 km/h, fix in last 15m.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_driver_aggregates_daily +-- Per-driver per-day km + speeding + harsh-driving aggregates. +-- TODO: Convert to TimescaleDB continuous aggregate once position_history > 100k rows. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_driver_aggregates_daily AS +WITH trips_agg AS ( + SELECT + imei, + (start_time AT TIME ZONE 'Africa/Nairobi')::date AS day, + SUM(distance_km) AS km, + COUNT(*) AS trips + FROM tracksolid.trips + WHERE start_time > NOW() - INTERVAL '31 days' + GROUP BY imei, (start_time AT TIME ZONE 'Africa/Nairobi')::date +), +speeding AS ( + SELECT + imei, + (gps_time AT TIME ZONE 'Africa/Nairobi')::date AS day, + COUNT(*) FILTER (WHERE speed > 80) AS events_80, + COUNT(*) FILTER (WHERE speed > 100) AS events_100, + COUNT(*) FILTER (WHERE speed > 120) AS events_120 + FROM tracksolid.position_history + WHERE gps_time > NOW() - INTERVAL '31 days' + AND speed IS NOT NULL + GROUP BY imei, (gps_time AT TIME ZONE 'Africa/Nairobi')::date +), +harsh_raw AS ( + SELECT + imei, + gps_time, + speed, + LAG(speed) OVER (PARTITION BY imei ORDER BY gps_time) AS prev_speed, + LAG(gps_time) OVER (PARTITION BY imei ORDER BY gps_time) AS prev_time + FROM tracksolid.position_history + WHERE source = 'track_list' + AND gps_time > NOW() - INTERVAL '31 days' +), +harsh AS ( + SELECT + imei, + (gps_time AT TIME ZONE 'Africa/Nairobi')::date AS day, + COUNT(*) AS harsh_events + FROM harsh_raw + WHERE ABS(speed - prev_speed) > 30 + AND EXTRACT(EPOCH FROM (gps_time - prev_time)) BETWEEN 5 AND 60 + GROUP BY imei, (gps_time AT TIME ZONE 'Africa/Nairobi')::date +) +SELECT + d.imei, + d.driver_name, + d.vehicle_number, + COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city, + t.day, + COALESCE(t.km, 0) AS km, + COALESCE(t.trips, 0) AS trips, + COALESCE(s.events_80, 0) AS events_80, + COALESCE(s.events_100, 0) AS events_100, + COALESCE(s.events_120, 0) AS events_120, + COALESCE(h.harsh_events, 0) AS harsh_events, + ROUND(COALESCE(s.events_80, 0) / NULLIF(t.km, 0) * 100, 2) AS speeding_per_100km, + ROUND(COALESCE(h.harsh_events, 0)/ NULLIF(t.km, 0) * 100, 2) AS harsh_per_100km +FROM trips_agg t +JOIN tracksolid.devices d ON d.imei = t.imei +LEFT JOIN speeding s ON s.imei = t.imei AND s.day = t.day +LEFT JOIN harsh h ON h.imei = t.imei AND h.day = t.day; + +COMMENT ON VIEW tracksolid.v_driver_aggregates_daily IS + '01_BusinessAnalytics.md §3.1 (speeding) + §3.2 (harsh driving). Daily grain; panels window via $__timeFilter(day).'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_fleet_km_daily +-- Fleet-wide daily distance, broken by assigned_city. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_fleet_km_daily AS +SELECT + (t.start_time AT TIME ZONE 'Africa/Nairobi')::date AS day, + COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city, + SUM(t.distance_km) AS km, + COUNT(DISTINCT t.imei) AS active_vehicles, + COUNT(*) AS trips +FROM tracksolid.trips t +JOIN tracksolid.devices d ON d.imei = t.imei +WHERE t.start_time > NOW() - INTERVAL '90 days' +GROUP BY 1, 2; + +COMMENT ON VIEW tracksolid.v_fleet_km_daily IS + '01_BusinessAnalytics.md §7 Panel 5 Distance Trend. City-cohort cut built in.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_alarms_daily +-- Daily alarm counts by alarm_name for the time-series panel. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_alarms_daily AS +SELECT + (alarm_time AT TIME ZONE 'Africa/Nairobi')::date AS day, + COALESCE(alarm_name, alarm_type, 'unknown') AS alarm_name, + COUNT(*) AS alarm_count +FROM tracksolid.alarms +WHERE alarm_time > NOW() - INTERVAL '90 days' +GROUP BY 1, 2; + +COMMENT ON VIEW tracksolid.v_alarms_daily IS + '01_BusinessAnalytics.md §7 Panel 7 Alarm Frequency. Stacked-by-alarm_name time series.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_utilisation_daily +-- Per-vehicle per-day utilisation from dwh_gold.fact_daily_fleet_metrics. +-- Empty until dwh_gold.refresh_daily_metrics nightly ETL is scheduled. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_utilisation_daily AS +SELECT + f.day, + d.imei, + d.vehicle_number, + d.driver_name, + COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city, + f.total_distance_km, + f.total_drive_hours, + f.total_idle_hours, + f.alarm_count, + f.overspeed_count, + ROUND( + f.total_drive_hours / NULLIF(f.total_drive_hours + f.total_idle_hours, 0) * 100, + 1 + ) AS utilisation_pct +FROM dwh_gold.fact_daily_fleet_metrics f +JOIN dwh_gold.dim_vehicles dv ON dv.vehicle_key = f.vehicle_key +JOIN tracksolid.devices d ON d.imei = dv.imei; + +COMMENT ON VIEW tracksolid.v_utilisation_daily IS + '01_BusinessAnalytics.md §7 Panel 8 Utilisation Heatmap. Empty until nightly ETL runs.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- v_sla_inflight +-- In-flight field-service tickets with SLA timers, joined to dispatch_log. +-- Empty until ops.tickets is populated from Zoho/Freshdesk or equivalent. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW tracksolid.v_sla_inflight AS +SELECT + t.ticket_id, + t.customer, + t.priority, + t.job_type, + t.status, + t.created_at, + t.assigned_at, + t.closed_at, + t.assigned_imei, + COALESCE(dl.driver_name, t.driver_name) AS driver_name, + dl.first_movement_at, + dl.on_site_at, + dl.resolved_at, + EXTRACT(EPOCH FROM (COALESCE(dl.first_movement_at, NOW()) - t.assigned_at)) / 60 AS dispatch_mins, + EXTRACT(EPOCH FROM (COALESCE(dl.on_site_at, NOW()) - dl.first_movement_at)) / 60 AS enroute_mins, + EXTRACT(EPOCH FROM (COALESCE(dl.resolved_at, NOW()) - dl.on_site_at)) / 60 AS onsite_mins, + EXTRACT(EPOCH FROM (COALESCE(dl.resolved_at, NOW()) - t.created_at)) / 60 AS resolution_mins, + CASE + WHEN dl.resolved_at IS NOT NULL THEN 'resolved' + WHEN dl.cancelled_at IS NOT NULL THEN 'cancelled' + WHEN dl.on_site_at IS NOT NULL THEN 'on_site' + WHEN dl.first_movement_at IS NOT NULL THEN 'en_route' + WHEN t.assigned_at IS NOT NULL THEN 'dispatched' + ELSE 'open' + END AS ticket_stage +FROM ops.tickets t +LEFT JOIN tracksolid.dispatch_log dl ON dl.ticket_id = t.ticket_id +WHERE t.status NOT IN ('resolved', 'cancelled') + OR t.closed_at > NOW() - INTERVAL '24 hours'; + +COMMENT ON VIEW tracksolid.v_sla_inflight IS + '01_BusinessAnalytics.md §4.5 Field-Service SLA Metrics. Open tickets + last 24h resolved.'; + + +-- ───────────────────────────────────────────────────────────────────────────── +-- Read access for Grafana +-- ───────────────────────────────────────────────────────────────────────────── +GRANT USAGE ON SCHEMA ops TO grafana_ro; + +GRANT SELECT ON tracksolid.v_fleet_today TO grafana_ro; +GRANT SELECT ON tracksolid.v_vehicles_not_moved_today TO grafana_ro; +GRANT SELECT ON tracksolid.v_active_dispatch_map TO grafana_ro; +GRANT SELECT ON tracksolid.v_currently_idle TO grafana_ro; +GRANT SELECT ON tracksolid.v_driver_aggregates_daily TO grafana_ro; +GRANT SELECT ON tracksolid.v_fleet_km_daily TO grafana_ro; +GRANT SELECT ON tracksolid.v_alarms_daily TO grafana_ro; +GRANT SELECT ON tracksolid.v_utilisation_daily TO grafana_ro; +GRANT SELECT ON tracksolid.v_sla_inflight TO grafana_ro; + +COMMIT; diff --git a/CLAUDE.md b/CLAUDE.md index 7cbd487..586282a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,8 +55,7 @@ See `docs/CONNECTIONS.md` for the full shape. Summary: - **SSH:** `ssh -i ~/.ssh/id_ed25519 kianiadee@stage.rahamafresh.com` - **DB name:** `tracksolid_db` · **DB user:** `postgres` (internal) · `tracksolid_owner` (app) · `grafana_ro` (read-only) -- **DB schema:** `tracksolid_2` (current live data, legacy stack) · `tracksolid` (new stack target, currently empty) · `infrastructure` · `dwh_gold` (aggregates) -- **⚠ Schema split:** new ingestion code targets `tracksolid`; all live rows are in `tracksolid_2` until the new stack is deployed and `sync_driver_audit.py` has run. +- **DB schemas:** `tracksolid` (live, single source of truth) · `dwh_gold` (aggregates) · `ops` (workshop / tickets / odometer) · `infrastructure`. The legacy `tracksolid_2` schema no longer exists — migrations 02–06 applied 2026-04-18. - **DB access:** `DATABASE_URL` points to `timescale_db:5432` (internal Docker network — not reachable locally). Use `docker exec` pattern above. See `docs/CONNECTIONS.md` for full reference. - **Container naming:** Coolify appends a random suffix. Always resolve with: ```bash @@ -96,29 +95,45 @@ tracksolidApiDocumentation.md # API endpoint reference ## 5. Database Schema — Key Tables ```sql -tracksolid_2.devices -- LIVE registry (63 AT4-series devices, 353549* IMEIs, 0 driver names) - -- NB: has `assigned_team` (not cost_centre), `city` (not assigned_city) -tracksolid_2.live_positions -- LIVE positions (19 rows, all stale since 6 Apr 2026) -tracksolid_2.ingestion_log -- LIVE pipeline audit trail -tracksolid.devices -- Target registry (empty — new stack not yet deployed) -tracksolid.live_positions -- Target positions (empty — new stack not yet deployed) -tracksolid.position_history -- All GPS fixes (hypertable, partitioned by gps_time) +tracksolid.devices -- Device / driver / vehicle registry (63 rows; 0 driver_name populated) + -- IMEI mix: 353549* AT4 (23), 862798* X3/JC400P (23), 865135* X3/JC400P (10), 359857* (7) + -- Full CSV (144 devices) not yet imported — run import_drivers_csv.py --apply +tracksolid.live_positions -- Current fix per IMEI (19 rows; refreshed every 60s by ingest_movement) +tracksolid.position_history -- All GPS fixes (hypertable, partitioned by gps_time). ~519 rows (308 track_list + 211 poll). + -- pg_stat_user_tables shows 0 for hypertables — always COUNT(*) directly. -- source: 'poll' (60s sweep) | 'track_list' (30m high-res) tracksolid.trips -- Trip summaries: distance_km, driving_time_s, avg/max speed -tracksolid.parking_events -- Stop events with duration and address -tracksolid.alarms -- Alarm events (alarm_type, alarm_name, alarm_time) +tracksolid.parking_events -- Stop events with duration and address (0 rows — endpoint returning empty) +tracksolid.alarms -- Alarm events (alarm_type, alarm_name, alarm_time) — 10 rows, polling healthy tracksolid.obd_readings -- OBD diagnostics (push only, awaiting webhook registration) -tracksolid.device_events -- Power on/off tamper events -tracksolid.ingestion_log -- API call audit trail per endpoint -tracksolid.dispatch_log -- Dispatch decisions for SLA tracking (migration 06) -dwh_gold.fact_daily_fleet_metrics -- Nightly ETL aggregates per vehicle per day +tracksolid.device_events -- Power on/off tamper events (push only) +tracksolid.ingestion_log -- API call audit trail — 875 runs / 24h, 0 failures at last check (2026-04-19) +tracksolid.dispatch_log -- Dispatch decisions for SLA tracking (migration 06; empty until ops integration) +tracksolid.schema_migrations -- Applied files: 02,03,04,05,06 (last 06 on 2026-04-18) +dwh_gold.fact_daily_fleet_metrics -- Nightly ETL aggregates per vehicle per day (run refresh_daily_metrics) ops.service_log -- Workshop service history (migration 06) ops.odometer_readings -- Physical odometer captures (migration 06) -ops.tickets -- Ticket skeleton for ops integration (migration 06) +ops.tickets -- Ticket skeleton for ops integration (migration 06; empty) ``` Full DDL: `02_tracksolid_full_schema_rev.sql` + migrations `03`–`06`. +**Analytics views (migration `07_analytics_views.sql`)** — one view per BA-file query block, readable by `grafana_ro`: + +```sql +tracksolid.v_fleet_today -- §9 per-vehicle today roll-up +tracksolid.v_vehicles_not_moved_today -- §2.3 alert source +tracksolid.v_active_dispatch_map -- §4.3 geomap source +tracksolid.v_currently_idle -- §2.2 idle lens +tracksolid.v_driver_aggregates_daily -- §3.1 + §3.2 aggression index source +tracksolid.v_fleet_km_daily -- §7 Panel 5 distance trend +tracksolid.v_alarms_daily -- §7 Panel 7 alarm frequency +tracksolid.v_utilisation_daily -- §7 Panel 8 utilisation heatmap (gated on dwh_gold ETL) +tracksolid.v_sla_inflight -- §4.5 SLA panels (gated on ops.tickets) +``` + +All views carry a `COMMENT ON VIEW` referencing their spec — `\d+ tracksolid.v_*` shows the provenance. + --- ## 6. API Critical Facts @@ -166,20 +181,22 @@ Full DDL: `02_tracksolid_full_schema_rev.sql` + migrations `03`–`06`. 7. **Secrets from env only.** Connection strings, API keys, and passwords live in `.env`. Reference variable names from `docs/CONNECTIONS.md`, never values. 8. **Two developers, one incoming.** Write code and docs that a second developer (mixed technical/operations background) can follow without prior context. 9. **Forgejo API auth:** credentials stored in macOS keychain. Retrieve with `git credential fill` (host=repo.rahamafresh.com). Use basic auth against `https://repo.rahamafresh.com/api/v1` directly — no `tea` or `gh` needed. -10. **Check active schema before querying.** Always verify which schema holds live data (`tracksolid` vs `tracksolid_2`) before running or writing queries. Until new stack deploys, live data is in `tracksolid_2`. +10. **Single live schema.** All live data lives in `tracksolid`. Aggregates live in `dwh_gold`; workshop/ticket integrations live in `ops`. Do not reintroduce references to the retired `tracksolid_2` schema. --- -## 9. Fleet State (as of 2026-04-18) +## 9. Fleet State (as of 2026-04-19) | Metric | Value | |---|---| -| Registered devices (live DB) | 63 AT4-series (`353549*` IMEIs) in `tracksolid_2` | -| Devices in CSV (not yet in DB) | 144 X3/JC400P (`865135*`, `862798*` IMEIs) | -| Driver names populated | 0 — run `import_drivers_csv.py --apply` after new stack deployed | -| Live positions | 19 (all stale — last fix 6 Apr 2026) | -| Trips recorded | 5 (12.8 km total, 4–6 Apr 2026 only) | -| Pipeline status | Stopped 6 Apr 2026 — 401 token expiry (fixed in `ts_shared_rev.py`, deploy new stack) | +| Registered devices (`tracksolid.devices`) | 63 total — 23 × `353549*` (AT4), 23 × `862798*` + 10 × `865135*` (X3/JC400P), 7 × `359857*` | +| Devices in CSV not yet imported | 144 (X3/JC400P); `import_drivers_csv.py --apply` will upsert names + plates | +| Driver names populated | 0 / 63 — pending CSV import | +| Live positions | 19 (latest fix 2026-04-19 10:25 UTC) | +| Trips recorded | 8 (latest 2026-04-19 08:34 UTC) | +| Alarms recorded | 10 (latest 2026-04-19 09:15 UTC) | +| `position_history` rows | 519 (308 `track_list` + 211 `poll`); hypertable stats don't update in `pg_stat_user_tables` — query directly | +| Pipeline status | Running healthy: 875 runs / 24h, 0 failures across all six endpoints | | Cities active | Nairobi (primary), Mombasa (deploying), Kampala (4 devices in CSV) | | Service flags | KDK 829A GP (239,264 km), Belta KCU-647D (235,000 km) | @@ -191,12 +208,11 @@ Latest full snapshot: `260412_baseline_report.md` | Priority | Item | |---|---| -| HIGH | Deploy new ingestion stack (see §8 Step 0 in `01_BusinessAnalytics.md` for full sequence) | -| HIGH | Run `import_drivers_csv.py --apply` after stack deployed (144 devices, names + plates ready) | +| HIGH | Run `import_drivers_csv.py --apply` — 144 X3/JC400P devices with names + plates waiting | | HIGH | Register webhooks: `/pushobd` `/pushoil` `/pushtem` `/pushlbs` `/pushevent` | | HIGH | Investigate X3-63282 in Kampala — legitimate or unauthorised? | | MEDIUM | Set `fuel_100km` per vehicle type to activate fuel cost calculations | -| MEDIUM | Investigate 44 silent devices — SIM installed? Activated? | +| MEDIUM | Investigate 44 silent devices (only 19 of 63 reporting) — SIM installed? Activated? | | MEDIUM | Co-develop client KPI framework (see `docs/KPI_FRAMEWORK.md`) | | LOW | Populate geofences — depot boundaries, city zones | -| LOW | Run nightly ETL: `SELECT dwh_gold.refresh_daily_metrics(CURRENT_DATE - 1)` | +| LOW | Schedule nightly ETL: `SELECT dwh_gold.refresh_daily_metrics(CURRENT_DATE - 1)` (cron or n8n) | diff --git a/docs/superpowers/specs/2026-04-19-daily-operations-dashboard-design.md b/docs/superpowers/specs/2026-04-19-daily-operations-dashboard-design.md new file mode 100644 index 0000000..75faf3c --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-daily-operations-dashboard-design.md @@ -0,0 +1,140 @@ +# Daily Operations Dashboard — Design Spec + +**Date:** 2026-04-19 +**Author:** David Kiania / Claude +**Status:** Approved for implementation +**Target:** `stage.rahamafresh.com` · `tracksolid_db` · `tracksolid` schema + +--- + +## 1. Purpose + +Extend the Grafana deployment with a second dashboard — **Daily Operations — Fleet & Dispatch** — that surfaces the `[DASHBOARD]`-tagged queries from `01_BusinessAnalytics.md`. Leaves the existing NOC Live dashboard untouched. + +Two lenses combined in one dashboard: + +- **Live Dispatch** — active map, currently-idle vehicles, vehicles-not-moved-today, in-flight SLAs. +- **§7 Blueprint Panels 3–8** — daily KPI table, driver leaderboard, distance trend, idle cost, alarm frequency, utilisation heatmap. + +Refresh cadence is 1 min. The existing `noc_fleet_dashboard.json` keeps its 30s refresh and its NOC folder. + +## 2. Scope + +### In scope +- New dashboard JSON: `grafana/provisioning/dashboards-json/daily_operations_dashboard.json`. +- New migration: `07_analytics_views.sql` — nine views in `tracksolid.*` wrapping the BA-file queries. +- Provisioning config update: add a second dashboard provider (or extend the existing one) so Grafana picks up the new dashboard on container start. +- CLAUDE.md corrections (stale schema + fleet state facts). + +### Out of scope +- Populating `ops.tickets` data (blocks SLA panels — they ship empty). +- Scheduling the nightly ETL (`dwh_gold.refresh_daily_metrics`) — utilisation heatmap renders empty until it runs. +- Any change to the NOC Live dashboard. +- Any ingestion code changes. + +## 3. Architecture + +Panels read **only** from views in `tracksolid.*`. No inline SQL in dashboards beyond a `SELECT * FROM tracksolid.v_* WHERE $__timeFilter(day) AND $city_filter`. + +Rationale: `01_BusinessAnalytics.md` §0 says "build panels against the tag, not the SQL text." Views give a single edit point when a metric definition changes. + +Views are **regular** (not materialised). `v_driver_aggregates_daily` is the one with performance risk once `position_history` grows; a `TODO` comment marks the spot for future conversion to a TimescaleDB continuous aggregate. + +## 4. Views + +All created in schema `tracksolid`, owned by `postgres`, `SELECT` granted to `grafana_ro`. Every view carries a `COMMENT ON VIEW` pointing back to the BA-file section. + +| View | Grain | Source | BA § | +|---|---|---|---| +| `v_fleet_today` | imei × today | `devices`, `trips`, `live_positions`, `alarms` | §9 | +| `v_vehicles_not_moved_today` | imei | `devices`, `trips`, `live_positions` | §2.3 | +| `v_active_dispatch_map` | imei | `live_positions`, `devices` | §4.3 | +| `v_currently_idle` | imei | `live_positions`, `devices` | §2.2 | +| `v_driver_aggregates_daily` | imei × day | `position_history`, `trips`, `devices` | §3.1 + §3.2 | +| `v_fleet_km_daily` | city × day | `trips`, `devices` | §7 P5 | +| `v_alarms_daily` | day × alarm_name | `alarms` | §7 P7 | +| `v_utilisation_daily` | imei × day | `dwh_gold.fact_daily_fleet_metrics`, `dwh_gold.dim_vehicles`, `devices` | §7 P8 | +| `v_sla_inflight` | ticket | `ops.tickets`, `dispatch_log` | §4.5 | + +## 5. Dashboard + +**File:** `grafana/provisioning/dashboards-json/daily_operations_dashboard.json` +**UID:** `daily-ops` +**Refresh:** `1m` +**Default time range:** `now/d` → `now` (Africa/Nairobi) + +### Variables +- `$city` — multi-select, query `SELECT DISTINCT COALESCE(assigned_city, 'unassigned') FROM tracksolid.devices ORDER BY 1`. Default All. +- `$active_only` — boolean, default true. Filters `devices.enabled_flag = 1` when true. + +### Layout + +**Freshness banner (top, full width):** +- Last GPS fix across all devices; green if < 5 min, amber 5–30 min, red > 30 min. + +**Row 1 — Today at a Glance** (6 stats across) +- Vehicles reporting today · Fleet km today · Drive hours · Idle hours · Open alarms (24h) · In-flight SLA jobs. + +**Row 2 — Live Dispatch** +- Active Vehicles Map (geomap) · Currently Idle Vehicles (table) · Vehicles Not Moved Today (table). + +**Row 3 — Daily KPI Table** +- One row per vehicle: Vehicle · Driver · Km Today · Trips · Drive h · Idle h · First Departure · Last Return · Alarms. + +**Row 4 — Driver Behaviour Leaderboard** (30-day) +- Columns: Driver · Vehicle · Km · Speeding/100km · Harsh/100km · Late starts · After-hours trips. +- Red/amber/green thresholds per BA-file §3.1 and §3.2 bands. + +**Row 5 — Trends** +- Distance Trend 7-day (time series, stacked by city). +- Alarm Frequency 30-day (bar chart, stacked by alarm_name). + +**Row 6 — Efficiency** +- Idle Cost Tracker (MTD idle hours and estimated KES wasted). +- Utilisation Heatmap (Y=vehicle, X=day-of-week). + +**Row 7 — Field-Service SLAs** (collapsed by default) +- Four SLA compliance stats + at-risk tickets table. All empty until `ops.tickets` flows. + +## 6. Provisioning + +Extend `grafana/provisioning/dashboards/noc_fleet.yaml` to expose both dashboards, or add a sibling provider file. Both dashboards live under `/etc/grafana/provisioning/dashboards-json/` and are baked into the image via `grafana/Dockerfile`. + +UI edits remain throwaway. + +## 7. Deployment + +1. Commit and push to `repo.rahamafresh.com`. +2. Coolify auto-pulls the commit, rebuilds the Grafana image, restarts. +3. `run_migrations.py` applies `07_analytics_views.sql` at `ingest_movement` container start. +4. New dashboard appears under the NOC folder on first Grafana load. + +### Rollback +- Revert the commit and push. Grafana image rebuilds without the new dashboard. +- Drop the views manually: `DROP VIEW IF EXISTS tracksolid.v_* CASCADE;` +- Remove the `schema_migrations` row for `07_analytics_views.sql`. + +## 8. Testing + +**Pre-push, against live DB:** +- Apply `07_analytics_views.sql` with `docker exec -i $DB psql -U postgres -d tracksolid_db`. +- `SELECT COUNT(*) FROM tracksolid.v_*` for each view — must return without error. Empty is OK. +- `SET ROLE grafana_ro; SELECT * FROM tracksolid.v_fleet_today LIMIT 1;` to confirm read grant. + +**Post-deploy, via browser:** +- Log into Grafana at the Coolify URL. +- Open "Daily Operations — Fleet & Dispatch". +- Confirm no red Grafana query errors. Empty panels acceptable for gated views (`v_utilisation_daily`, `v_sla_inflight`). +- Confirm `$city` variable populates (all `unassigned` today). +- Confirm freshness banner reflects latest `live_positions.gps_time`. + +## 9. Known Data Gaps (panels ship, light up later) + +| Panel | Blocked on | +|---|---| +| Utilisation Heatmap | Nightly `dwh_gold.refresh_daily_metrics` job not yet scheduled | +| SLA row | `ops.tickets` and `dispatch_log` empty until Zoho/Freshdesk integration | +| Driver Leaderboard (harsh) | `position_history` growth; 519 rows today | +| Idle Cost Tracker | `dwh_gold.fact_daily_fleet_metrics` (same dependency as heatmap) | + +All queries are written against the target tables so panels light up automatically when upstream data flows. diff --git a/grafana/provisioning/dashboards-json/daily_operations_dashboard.json b/grafana/provisioning/dashboards-json/daily_operations_dashboard.json new file mode 100644 index 0000000..d4a935b --- /dev/null +++ b/grafana/provisioning/dashboards-json/daily_operations_dashboard.json @@ -0,0 +1,594 @@ +{ + "title": "Daily Operations — Fleet & Dispatch", + "uid": "daily-ops", + "schemaVersion": 39, + "version": 1, + "refresh": "1m", + "time": { "from": "now/d", "to": "now" }, + "timezone": "Africa/Nairobi", + "timepicker": { + "refresh_intervals": ["30s", "1m", "5m", "15m", "30m", "1h"] + }, + "editable": false, + "tags": ["fleet", "daily", "dispatch", "ops"], + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "templating": { + "list": [ + { + "name": "city", + "label": "City", + "type": "query", + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "query": "SELECT DISTINCT COALESCE(assigned_city, city, 'unassigned') AS city FROM tracksolid.devices ORDER BY 1", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "current": { "selected": true, "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { + "id": 100, + "type": "stat", + "title": "Last GPS Fix (fleet)", + "description": "Most recent live position across all devices. Green < 5 min, amber 5–30 min, red > 30 min.", + "gridPos": { "x": 0, "y": 0, "w": 24, "h": 3 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value_and_name" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 300 }, + { "color": "red", "value": 1800 } + ] + } + } + }, + "targets": [ + { + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT EXTRACT(EPOCH FROM (NOW() - MAX(gps_time)))::int AS \"Seconds since latest fleet fix\" FROM tracksolid.live_positions;", + "format": "table", + "refId": "A" + } + ] + }, + { + "id": 101, + "type": "row", + "title": "Row 1 — Today at a Glance", + "collapsed": false, + "gridPos": { "x": 0, "y": 3, "w": 24, "h": 1 }, + "panels": [] + }, + { + "id": 110, + "type": "stat", + "title": "Vehicles reporting today", + "gridPos": { "x": 0, "y": 4, "w": 4, "h": 4 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "background", "graphMode": "none", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT COUNT(*) FILTER (WHERE trips_today > 0) AS \"Reporting today\" FROM tracksolid.v_fleet_today WHERE assigned_city ~ '${city:regex}';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 111, + "type": "stat", + "title": "Fleet km today", + "gridPos": { "x": 4, "y": 4, "w": 4, "h": 4 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "value", "graphMode": "area", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "fieldConfig": { + "defaults": { + "unit": "lengthkm", + "decimals": 1, + "color": { "mode": "fixed", "fixedColor": "blue" } + } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT ROUND(SUM(km_today)::numeric, 1) AS \"Fleet km today\" FROM tracksolid.v_fleet_today WHERE assigned_city ~ '${city:regex}';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 112, + "type": "stat", + "title": "Drive hours today", + "gridPos": { "x": 8, "y": 4, "w": 4, "h": 4 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "value", "graphMode": "none", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "fieldConfig": { + "defaults": { "unit": "h", "decimals": 1, "color": { "mode": "fixed", "fixedColor": "green" } } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT ROUND(SUM(drive_hours)::numeric, 1) AS \"Drive h\" FROM tracksolid.v_fleet_today WHERE assigned_city ~ '${city:regex}';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 113, + "type": "stat", + "title": "Idle hours today", + "description": "Ignition on, speed ~0. Fuel burn with no movement.", + "gridPos": { "x": 12, "y": 4, "w": 4, "h": 4 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "value", "graphMode": "none", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "fieldConfig": { + "defaults": { + "unit": "h", "decimals": 1, + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 30 } + ] + } + } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT ROUND(SUM(idle_hours)::numeric, 1) AS \"Idle h\" FROM tracksolid.v_fleet_today WHERE assigned_city ~ '${city:regex}';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 114, + "type": "stat", + "title": "Open alarms (24h)", + "gridPos": { "x": 16, "y": 4, "w": 4, "h": 4 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "background", "graphMode": "none", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 10 } + ] + } + } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT COUNT(*) AS \"Alarms 24h\" FROM tracksolid.alarms WHERE alarm_time > NOW() - INTERVAL '24 hours';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 115, + "type": "stat", + "title": "In-flight SLA jobs", + "description": "Tickets currently open and dispatched. Empty until ops.tickets flows.", + "gridPos": { "x": 20, "y": 4, "w": 4, "h": 4 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "value", "graphMode": "none", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "fieldConfig": { + "defaults": { "color": { "mode": "fixed", "fixedColor": "purple" } } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT COUNT(*) AS \"In-flight\" FROM tracksolid.v_sla_inflight WHERE ticket_stage NOT IN ('resolved', 'cancelled');", + "format": "table", "refId": "A" } + ] + }, + { + "id": 120, + "type": "row", + "title": "Row 2 — Live Dispatch", + "collapsed": false, + "gridPos": { "x": 0, "y": 8, "w": 24, "h": 1 }, + "panels": [] + }, + { + "id": 121, + "type": "geomap", + "title": "Active Vehicles Map", + "gridPos": { "x": 0, "y": 9, "w": 14, "h": 14 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "basemap": { "config": { "theme": "dark" }, "name": "Basemap", "type": "carto" }, + "controls": { "mouseWheelZoom": true, "showAttribution": true, "showScale": true, "showZoom": true }, + "layers": [ + { + "config": { + "showLegend": true, + "style": { + "color": { "field": "status", "fixed": "green", "mode": "field" }, + "opacity": 0.9, + "rotation": { "field": "direction", "fixed": 0, "max": 360, "min": -360, "mode": "field" }, + "size": { "fixed": 14, "max": 15, "min": 2, "mode": "fixed" }, + "symbol": { "fixed": "img/icons/marker/circle.svg", "mode": "fixed" } + } + }, + "filterData": { "id": "byRefId", "options": "A" }, + "location": { "latitude": "lat", "longitude": "lng", "mode": "coords" }, + "name": "Vehicles", "tooltip": true, "type": "markers" + } + ], + "tooltip": { "mode": "details" }, + "view": { "allLayers": true, "id": "coords", "lat": -1.5, "lon": 36.5, "zoom": 6 } + }, + "fieldConfig": { + "defaults": { "color": { "mode": "palette-classic-by-name" } }, + "overrides": [ + { "matcher": { "id": "byName", "options": "status" }, + "properties": [ + { "id": "mappings", "value": [{ "type": "value", "options": { + "moving": { "color": "green", "index": 0, "text": "Moving" }, + "idle_ignition_on": { "color": "yellow", "index": 1, "text": "Idle (engine on)" }, + "parked": { "color": "blue", "index": 2, "text": "Parked" }, + "stale": { "color": "orange", "index": 3, "text": "Stale > 10m" }, + "never_reported": { "color": "red", "index": 4, "text": "Never reported" } + } }] } + ] + } + ] + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT imei, vehicle_number, driver_name, assigned_city, lat, lng, speed, direction, status, last_fix FROM tracksolid.v_active_dispatch_map WHERE lat IS NOT NULL AND lng IS NOT NULL AND assigned_city ~ '${city:regex}';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 122, + "type": "table", + "title": "Currently Idle (engine on, speed < 2)", + "gridPos": { "x": 14, "y": 9, "w": 10, "h": 7 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "cellHeight": "sm", "showHeader": true, "footer": { "show": false } }, + "fieldConfig": { + "defaults": { "custom": { "align": "auto", "filterable": true } }, + "overrides": [ + { "matcher": { "id": "byName", "options": "idle_seconds" }, + "properties": [{ "id": "unit", "value": "s" }, { "id": "displayName", "value": "Idle for" }] } + ] + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT vehicle_number, driver_name, assigned_city, since, idle_seconds FROM tracksolid.v_currently_idle WHERE assigned_city ~ '${city:regex}' ORDER BY idle_seconds DESC;", + "format": "table", "refId": "A" } + ] + }, + { + "id": 123, + "type": "table", + "title": "Vehicles Not Moved Today", + "gridPos": { "x": 14, "y": 16, "w": 10, "h": 7 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "cellHeight": "sm", "showHeader": true, "footer": { "show": false } }, + "fieldConfig": { + "defaults": { "custom": { "align": "auto", "filterable": true } } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT vehicle_number, driver_name, assigned_city, last_seen FROM tracksolid.v_vehicles_not_moved_today WHERE assigned_city ~ '${city:regex}' ORDER BY last_seen DESC NULLS LAST;", + "format": "table", "refId": "A" } + ] + }, + { + "id": 130, + "type": "row", + "title": "Row 3 — Daily KPI Table", + "collapsed": false, + "gridPos": { "x": 0, "y": 23, "w": 24, "h": 1 }, + "panels": [] + }, + { + "id": 131, + "type": "table", + "title": "Per-Vehicle Daily Roll-up", + "gridPos": { "x": 0, "y": 24, "w": 24, "h": 12 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "cellHeight": "sm", "showHeader": true, "footer": { "show": false } }, + "fieldConfig": { + "defaults": { "custom": { "align": "auto", "filterable": true } }, + "overrides": [ + { "matcher": { "id": "byName", "options": "km_today" }, + "properties": [{ "id": "unit", "value": "lengthkm" }, { "id": "decimals", "value": 1 }] }, + { "matcher": { "id": "byName", "options": "drive_hours" }, + "properties": [{ "id": "unit", "value": "h" }, { "id": "decimals", "value": 1 }] }, + { "matcher": { "id": "byName", "options": "idle_hours" }, + "properties": [{ "id": "unit", "value": "h" }, { "id": "decimals", "value": 1 }] }, + { "matcher": { "id": "byName", "options": "did_not_move" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "type": "color-background" } }, + { "id": "mappings", "value": [{ "type": "value", "options": { + "true": { "color": "red", "index": 0, "text": "No" }, + "false": { "color": "transparent", "index": 1, "text": "Yes" } + } }] }, + { "id": "displayName", "value": "Moved?" } + ] + } + ] + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT vehicle_number, driver_name, assigned_city, km_today, trips_today, drive_hours, idle_hours, first_departure, last_return, alarms_today, did_not_move FROM tracksolid.v_fleet_today WHERE enabled_flag = 1 AND assigned_city ~ '${city:regex}' ORDER BY km_today DESC NULLS LAST;", + "format": "table", "refId": "A" } + ] + }, + { + "id": 140, + "type": "row", + "title": "Row 4 — Driver Behaviour Leaderboard (30-day)", + "collapsed": false, + "gridPos": { "x": 0, "y": 36, "w": 24, "h": 1 }, + "panels": [] + }, + { + "id": 141, + "type": "table", + "title": "Driver Leaderboard", + "description": "Rolling 30-day aggression index. Red/amber/green per BA-file §3.1–§3.2 thresholds.", + "gridPos": { "x": 0, "y": 37, "w": 24, "h": 12 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "cellHeight": "sm", "showHeader": true, "footer": { "show": false } }, + "fieldConfig": { + "defaults": { "custom": { "align": "auto", "filterable": true } }, + "overrides": [ + { "matcher": { "id": "byName", "options": "km" }, + "properties": [{ "id": "unit", "value": "lengthkm" }, { "id": "decimals", "value": 0 }] }, + { "matcher": { "id": "byName", "options": "speeding_per_100km" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "type": "color-background" } }, + { "id": "thresholds", "value": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] } } + ] + }, + { "matcher": { "id": "byName", "options": "harsh_per_100km" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "type": "color-background" } }, + { "id": "thresholds", "value": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 2 } + ] } } + ] + } + ] + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT driver_name, vehicle_number, assigned_city, SUM(km)::numeric(10,0) AS km, SUM(events_80) AS events_80, SUM(events_100) AS events_100, SUM(events_120) AS events_120, SUM(harsh_events) AS harsh_events, ROUND(SUM(events_80)::numeric / NULLIF(SUM(km), 0) * 100, 2) AS speeding_per_100km, ROUND(SUM(harsh_events)::numeric / NULLIF(SUM(km), 0) * 100, 2) AS harsh_per_100km FROM tracksolid.v_driver_aggregates_daily WHERE day > CURRENT_DATE - INTERVAL '30 days' AND assigned_city ~ '${city:regex}' GROUP BY driver_name, vehicle_number, assigned_city ORDER BY harsh_per_100km DESC NULLS LAST;", + "format": "table", "refId": "A" } + ] + }, + { + "id": 150, + "type": "row", + "title": "Row 5 — Trends", + "collapsed": false, + "gridPos": { "x": 0, "y": 49, "w": 24, "h": 1 }, + "panels": [] + }, + { + "id": 151, + "type": "timeseries", + "title": "Fleet Distance — 7-day (by city)", + "gridPos": { "x": 0, "y": 50, "w": 12, "h": 9 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "fieldConfig": { + "defaults": { + "unit": "lengthkm", + "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1 } + } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT day::timestamptz AS time, assigned_city AS metric, km AS value FROM tracksolid.v_fleet_km_daily WHERE day > CURRENT_DATE - INTERVAL '7 days' AND assigned_city ~ '${city:regex}' ORDER BY day;", + "format": "time_series", "refId": "A" } + ] + }, + { + "id": 152, + "type": "timeseries", + "title": "Alarm Frequency — 30-day (by type)", + "gridPos": { "x": 12, "y": 50, "w": 12, "h": 9 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1, "stacking": { "mode": "normal", "group": "A" } } + } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT day::timestamptz AS time, alarm_name AS metric, alarm_count AS value FROM tracksolid.v_alarms_daily WHERE day > CURRENT_DATE - INTERVAL '30 days' ORDER BY day;", + "format": "time_series", "refId": "A" } + ] + }, + { + "id": 160, + "type": "row", + "title": "Row 6 — Efficiency", + "collapsed": false, + "gridPos": { "x": 0, "y": 59, "w": 24, "h": 1 }, + "panels": [] + }, + { + "id": 161, + "type": "stat", + "title": "Idle Cost (month-to-date)", + "description": "Sum of idle hours × 0.8 L/h × 180 KES/L across fleet for this month. Empty until nightly ETL refreshes dwh_gold.", + "gridPos": { "x": 0, "y": 60, "w": 12, "h": 6 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "colorMode": "value", "graphMode": "area", "textMode": "value_and_name", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyKES", "decimals": 0, + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50000 }, + { "color": "red", "value": 200000 } + ] } + } + }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT ROUND(SUM(total_idle_hours) * 0.8 * 180) AS \"Idle cost KES (MTD)\" FROM tracksolid.v_utilisation_daily WHERE day >= DATE_TRUNC('month', CURRENT_DATE) AND assigned_city ~ '${city:regex}';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 162, + "type": "heatmap", + "title": "Utilisation Heatmap (30-day)", + "description": "Per-vehicle daily utilisation %. Empty until dwh_gold.fact_daily_fleet_metrics is refreshed by the nightly ETL.", + "gridPos": { "x": 12, "y": 60, "w": 12, "h": 9 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "calculate": false, + "cellGap": 2, + "color": { "mode": "scheme", "scheme": "RdYlGn", "steps": 64 }, + "yAxis": { "axisLabel": "Vehicle" } + }, + "fieldConfig": { "defaults": { "custom": { "scaleDistribution": { "type": "linear" } } } }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT day::timestamptz AS time, vehicle_number AS metric, utilisation_pct AS value FROM tracksolid.v_utilisation_daily WHERE day > CURRENT_DATE - INTERVAL '30 days' AND assigned_city ~ '${city:regex}' ORDER BY day;", + "format": "time_series", "refId": "A" } + ] + }, + { + "id": 170, + "type": "row", + "title": "Row 7 — Field-Service SLAs (data-gated)", + "collapsed": true, + "gridPos": { "x": 0, "y": 69, "w": 24, "h": 1 }, + "panels": [ + { + "id": 171, + "type": "stat", + "title": "Dispatch SLA (median mins, 24h)", + "gridPos": { "x": 0, "y": 70, "w": 6, "h": 5 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "fieldConfig": { "defaults": { "unit": "m", "decimals": 0 } }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY dispatch_mins) AS \"Dispatch p50 (min)\" FROM tracksolid.v_sla_inflight WHERE created_at > NOW() - INTERVAL '24 hours';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 172, + "type": "stat", + "title": "En-route SLA (median mins, 24h)", + "gridPos": { "x": 6, "y": 70, "w": 6, "h": 5 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "fieldConfig": { "defaults": { "unit": "m", "decimals": 0 } }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY enroute_mins) AS \"En-route p50 (min)\" FROM tracksolid.v_sla_inflight WHERE created_at > NOW() - INTERVAL '24 hours';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 173, + "type": "stat", + "title": "On-site SLA (median mins, 24h)", + "gridPos": { "x": 12, "y": 70, "w": 6, "h": 5 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "fieldConfig": { "defaults": { "unit": "m", "decimals": 0 } }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY onsite_mins) AS \"On-site p50 (min)\" FROM tracksolid.v_sla_inflight WHERE created_at > NOW() - INTERVAL '24 hours';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 174, + "type": "stat", + "title": "Resolution SLA (median mins, 24h)", + "gridPos": { "x": 18, "y": 70, "w": 6, "h": 5 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "fieldConfig": { "defaults": { "unit": "m", "decimals": 0 } }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_mins) AS \"Resolution p50 (min)\" FROM tracksolid.v_sla_inflight WHERE created_at > NOW() - INTERVAL '24 hours';", + "format": "table", "refId": "A" } + ] + }, + { + "id": 175, + "type": "table", + "title": "At-risk tickets", + "gridPos": { "x": 0, "y": 75, "w": 24, "h": 10 }, + "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "options": { "cellHeight": "sm", "showHeader": true, "footer": { "show": false } }, + "fieldConfig": { "defaults": { "custom": { "align": "auto", "filterable": true } } }, + "targets": [ + { "datasource": { "type": "postgres", "uid": "tracksolid_pg" }, + "rawSql": "SELECT ticket_id, customer, priority, ticket_stage, driver_name, created_at, dispatch_mins, enroute_mins, onsite_mins, resolution_mins FROM tracksolid.v_sla_inflight WHERE ticket_stage NOT IN ('resolved', 'cancelled') ORDER BY resolution_mins DESC NULLS LAST LIMIT 50;", + "format": "table", "refId": "A" } + ] + } + ] + } + ] +} diff --git a/run_migrations.py b/run_migrations.py index 8b148e9..3a98169 100644 --- a/run_migrations.py +++ b/run_migrations.py @@ -27,8 +27,10 @@ DATABASE_URL = os.environ["DATABASE_URL"] MIGRATIONS = [ "02_tracksolid_full_schema_rev.sql", "03_webhook_schema_migration.sql", - "04_bug_fix_migration.sql", # distance_m → distance_km rename + correction - "05_enhancement_migration.sql", # new tables, OBD columns, dwh_gold expansion + "04_bug_fix_migration.sql", # distance_m → distance_km rename + correction + "05_enhancement_migration.sql", # new tables, OBD columns, dwh_gold expansion + "06_business_analytics_migration.sql", # ops schema, dispatch_log, assigned_city + "07_analytics_views.sql", # Grafana-facing views in tracksolid.* ] # ── Tables that must exist before the service is allowed to start ───────────── @@ -105,6 +107,16 @@ def seed_pre_tracking_migrations(conn): "SELECT 1 FROM information_schema.tables " "WHERE table_schema='tracksolid' AND table_name='device_events'", ), + ( + "06_business_analytics_migration.sql", + "SELECT 1 FROM information_schema.tables " + "WHERE table_schema='ops' AND table_name='tickets'", + ), + ( + "07_analytics_views.sql", + "SELECT 1 FROM information_schema.views " + "WHERE table_schema='tracksolid' AND table_name='v_fleet_today'", + ), ] seeds = []