Compare commits
No commits in common. "2603f0e726b83d8529d053a0d351f0525aa3c71b" and "3015104f5b1883f24cb52acc3567b863d83dab9c" have entirely different histories.
2603f0e726
...
3015104f5b
8 changed files with 10 additions and 804 deletions
|
|
@ -42,7 +42,6 @@ from urllib.parse import parse_qs
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.encoders import jsonable_encoder
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
@ -69,10 +68,6 @@ _ALLOWED_ORIGINS = [
|
||||||
# uvicorn workers (only one worker refreshes per tick); the work runs in a
|
# uvicorn workers (only one worker refreshes per tick); the work runs in a
|
||||||
# thread so the async event loop never blocks on the ~9s REFRESH.
|
# thread so the async event loop never blocks on the ~9s REFRESH.
|
||||||
_DATABASE_URL = os.environ["DATABASE_URL"]
|
_DATABASE_URL = os.environ["DATABASE_URL"]
|
||||||
# VTRIPS_REFRESH_INTERVAL_S <= 0 disables the in-process refresher entirely.
|
|
||||||
# Staging sets it to 0: it connects read-only and prod owns the refresh, so a
|
|
||||||
# staging instance must never attempt REFRESH (it would only log permission
|
|
||||||
# errors). Prod keeps the 300s default.
|
|
||||||
_REFRESH_INTERVAL_S = int(os.getenv("VTRIPS_REFRESH_INTERVAL_S", "300"))
|
_REFRESH_INTERVAL_S = int(os.getenv("VTRIPS_REFRESH_INTERVAL_S", "300"))
|
||||||
_REFRESH_LOCK_KEY = 920_145 # arbitrary, stable advisory-lock key for this job
|
_REFRESH_LOCK_KEY = 920_145 # arbitrary, stable advisory-lock key for this job
|
||||||
|
|
||||||
|
|
@ -124,26 +119,17 @@ async def _refresh_loop():
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
refresher = None
|
log.info(
|
||||||
if _REFRESH_INTERVAL_S > 0:
|
"Dashboard API starting (v1.1). Origins=%s. v_trips refresh every %ss.",
|
||||||
log.info(
|
_ALLOWED_ORIGINS, _REFRESH_INTERVAL_S,
|
||||||
"Dashboard API starting (v1.2). Origins=%s. v_trips refresh every %ss.",
|
)
|
||||||
_ALLOWED_ORIGINS, _REFRESH_INTERVAL_S,
|
refresher = asyncio.create_task(_refresh_loop())
|
||||||
)
|
|
||||||
refresher = asyncio.create_task(_refresh_loop())
|
|
||||||
else:
|
|
||||||
log.info(
|
|
||||||
"Dashboard API starting (v1.2). Origins=%s. v_trips refresher DISABLED "
|
|
||||||
"(VTRIPS_REFRESH_INTERVAL_S<=0) — read-only / staging mode.",
|
|
||||||
_ALLOWED_ORIGINS,
|
|
||||||
)
|
|
||||||
yield
|
yield
|
||||||
if refresher is not None:
|
refresher.cancel()
|
||||||
refresher.cancel()
|
try:
|
||||||
try:
|
await refresher
|
||||||
await refresher
|
except asyncio.CancelledError:
|
||||||
except asyncio.CancelledError:
|
pass
|
||||||
pass
|
|
||||||
close_pool()
|
close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -336,288 +322,3 @@ async def fleet_trips(request: Request):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": {"type": "unknown", "message": "Fleet feed is unavailable. Try again in a few seconds."}}
|
{"error": {"type": "unknown", "message": "Fleet feed is unavailable. Try again in a few seconds."}}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── FleetOps analytics (#15) ─────────────────────────────────────────────────
|
|
||||||
# Read-only roll-ups powering the FleetOps SPA (fleetops.rahamafresh.com):
|
|
||||||
# utilisation, distance, driver behaviour and fuel. Every query SELECTs the
|
|
||||||
# indexed reporting.* / tracksolid.v_* views and never writes — so the staging
|
|
||||||
# instance serves them against the prod DB as a read-only role. Numeric/date
|
|
||||||
# values come back as Decimal/date from psycopg2, so responses pass through
|
|
||||||
# jsonable_encoder (Decimal→float, date→ISO) before JSONResponse.
|
|
||||||
#
|
|
||||||
# GET /analytics/fleet-summary per-vehicle + per-cost-centre roll-up
|
|
||||||
# GET /analytics/utilisation per-vehicle utilisation + daily fleet trend
|
|
||||||
# GET /analytics/driver-behaviour per-driver speeding / harsh index
|
|
||||||
# GET /analytics/fuel actual vs estimated litres (data-gated)
|
|
||||||
# GET /analytics/filters dropdown options (alias of GET /webhook/fleet-dashboard)
|
|
||||||
#
|
|
||||||
# Shared query params: period (today|7d|30d|custom, default 30d), start_date,
|
|
||||||
# end_date, and optional dims cost_centre / assigned_city / vehicle_number /
|
|
||||||
# driver.
|
|
||||||
|
|
||||||
def _json(obj):
|
|
||||||
"""Serialise dicts that may carry Decimal / date values from psycopg2."""
|
|
||||||
return JSONResponse(jsonable_encoder(obj))
|
|
||||||
|
|
||||||
|
|
||||||
def _analytics_window(period, start_date, end_date):
|
|
||||||
"""Date range for analytics — defaults to a 30-day window (vs the 7d trips default)."""
|
|
||||||
return _preset_to_range(period or "30d", start_date, end_date)
|
|
||||||
|
|
||||||
|
|
||||||
def _dim_filters(cost_centre=None, assigned_city=None, vehicle_number=None, driver=None):
|
|
||||||
"""Optional WHERE fragments shared by the reporting.* analytics views.
|
|
||||||
|
|
||||||
Column names are fixed literals (not user input); only the values are
|
|
||||||
parameterised, so interpolating the fragments into the query is injection-safe.
|
|
||||||
"""
|
|
||||||
clauses, params = [], {}
|
|
||||||
for col, val in (
|
|
||||||
("cost_centre", cost_centre),
|
|
||||||
("assigned_city", assigned_city),
|
|
||||||
("vehicle_number", vehicle_number),
|
|
||||||
("assigned_driver", driver),
|
|
||||||
):
|
|
||||||
v = _clean(val)
|
|
||||||
if v is not None:
|
|
||||||
clauses.append(f"{col} = %({col})s")
|
|
||||||
params[col] = v
|
|
||||||
return clauses, params
|
|
||||||
|
|
||||||
|
|
||||||
def _analytics_error(name):
|
|
||||||
log.exception("%s failed", name)
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": {"type": "unknown",
|
|
||||||
"message": "Analytics feed is unavailable. Try again in a few seconds."}}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analytics/fleet-summary")
|
|
||||||
def analytics_fleet_summary(
|
|
||||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
|
||||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
|
||||||
vehicle_number: str | None = None, driver: str | None = None,
|
|
||||||
):
|
|
||||||
start, end = _analytics_window(period, start_date, end_date)
|
|
||||||
clauses, params = _dim_filters(cost_centre, assigned_city, vehicle_number, driver)
|
|
||||||
params |= {"start": start, "end": end}
|
|
||||||
where = " AND ".join(["trip_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT vehicle_number, cost_centre, assigned_city, assigned_driver,
|
|
||||||
count(DISTINCT trip_date) AS active_days,
|
|
||||||
sum(trip_count) AS trips,
|
|
||||||
round(sum(total_km), 1) AS total_km,
|
|
||||||
round(sum(driving_hours), 1) AS driving_hours,
|
|
||||||
round(sum(idle_hours), 1) AS idle_hours,
|
|
||||||
round(100.0 * sum(idle_hours)
|
|
||||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct,
|
|
||||||
round(max(max_speed_kmh)) AS max_speed_kmh
|
|
||||||
FROM reporting.v_daily_summary
|
|
||||||
WHERE {where}
|
|
||||||
GROUP BY vehicle_number, cost_centre, assigned_city, assigned_driver
|
|
||||||
ORDER BY total_km DESC NULLS LAST
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT cost_centre,
|
|
||||||
count(DISTINCT vehicle_number) AS vehicles,
|
|
||||||
sum(trip_count) AS trips,
|
|
||||||
round(sum(total_km), 1) AS total_km,
|
|
||||||
round(sum(driving_hours), 1) AS driving_hours,
|
|
||||||
round(sum(idle_hours), 1) AS idle_hours,
|
|
||||||
round(100.0 * sum(idle_hours)
|
|
||||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct
|
|
||||||
FROM reporting.v_daily_summary
|
|
||||||
WHERE {where}
|
|
||||||
GROUP BY cost_centre
|
|
||||||
ORDER BY total_km DESC NULLS LAST
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
by_cc = cur.fetchall()
|
|
||||||
totals = {
|
|
||||||
"vehicles": len({r["vehicle_number"] for r in rows}),
|
|
||||||
"trips": sum(int(r["trips"] or 0) for r in rows),
|
|
||||||
"total_km": round(sum(float(r["total_km"] or 0) for r in rows), 1),
|
|
||||||
"driving_hours": round(sum(float(r["driving_hours"] or 0) for r in rows), 1),
|
|
||||||
"idle_hours": round(sum(float(r["idle_hours"] or 0) for r in rows), 1),
|
|
||||||
}
|
|
||||||
return _json({"window": {"start": str(start), "end": str(end)},
|
|
||||||
"totals": totals, "rows": rows, "by_cost_centre": by_cc})
|
|
||||||
except Exception:
|
|
||||||
return _analytics_error("analytics/fleet-summary")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analytics/utilisation")
|
|
||||||
def analytics_utilisation(
|
|
||||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
|
||||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
|
||||||
vehicle_number: str | None = None, driver: str | None = None,
|
|
||||||
):
|
|
||||||
start, end = _analytics_window(period, start_date, end_date)
|
|
||||||
clauses, params = _dim_filters(cost_centre, assigned_city, vehicle_number, driver)
|
|
||||||
params |= {"start": start, "end": end}
|
|
||||||
where = " AND ".join(["trip_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT vehicle_number, cost_centre, assigned_city,
|
|
||||||
count(DISTINCT trip_date) AS active_days,
|
|
||||||
round(sum(total_km), 1) AS total_km,
|
|
||||||
round(sum(total_km)
|
|
||||||
/ NULLIF(count(DISTINCT trip_date), 0), 1) AS km_per_active_day,
|
|
||||||
round(sum(driving_hours), 1) AS driving_hours,
|
|
||||||
round(sum(idle_hours), 1) AS idle_hours,
|
|
||||||
round(100.0 * sum(idle_hours)
|
|
||||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct
|
|
||||||
FROM reporting.v_daily_summary
|
|
||||||
WHERE {where}
|
|
||||||
GROUP BY vehicle_number, cost_centre, assigned_city
|
|
||||||
ORDER BY total_km DESC NULLS LAST
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
by_vehicle = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT trip_date,
|
|
||||||
count(DISTINCT vehicle_number) AS active_vehicles,
|
|
||||||
round(sum(total_km), 1) AS total_km,
|
|
||||||
round(sum(driving_hours), 1) AS driving_hours,
|
|
||||||
round(sum(idle_hours), 1) AS idle_hours,
|
|
||||||
round(100.0 * sum(idle_hours)
|
|
||||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct
|
|
||||||
FROM reporting.v_daily_summary
|
|
||||||
WHERE {where}
|
|
||||||
GROUP BY trip_date
|
|
||||||
ORDER BY trip_date
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
daily_trend = cur.fetchall()
|
|
||||||
return _json({"window": {"start": str(start), "end": str(end)},
|
|
||||||
"by_vehicle": by_vehicle, "daily_trend": daily_trend})
|
|
||||||
except Exception:
|
|
||||||
return _analytics_error("analytics/utilisation")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analytics/driver-behaviour")
|
|
||||||
def analytics_driver_behaviour(
|
|
||||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
|
||||||
assigned_city: str | None = None, driver: str | None = None,
|
|
||||||
):
|
|
||||||
start, end = _analytics_window(period, start_date, end_date)
|
|
||||||
clauses = ["day BETWEEN %(start)s AND %(end)s", "driver_name IS NOT NULL"]
|
|
||||||
params = {"start": start, "end": end}
|
|
||||||
city, drv = _clean(assigned_city), _clean(driver)
|
|
||||||
if city is not None:
|
|
||||||
clauses.append("assigned_city = %(city)s")
|
|
||||||
params["city"] = city
|
|
||||||
if drv is not None:
|
|
||||||
clauses.append("driver_name = %(drv)s")
|
|
||||||
params["drv"] = drv
|
|
||||||
where = " AND ".join(clauses)
|
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT driver_name, assigned_city,
|
|
||||||
count(DISTINCT day) AS active_days,
|
|
||||||
round(sum(km), 1) AS total_km,
|
|
||||||
sum(trips) AS trips,
|
|
||||||
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 {where}
|
|
||||||
GROUP BY driver_name, assigned_city
|
|
||||||
ORDER BY speeding_per_100km DESC NULLS LAST
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
# driver_name is 0/63 populated until import_drivers_csv.py --apply runs,
|
|
||||||
# so this legitimately returns [] today; it fills in once drivers land.
|
|
||||||
return _json({"window": {"start": str(start), "end": str(end)},
|
|
||||||
"drivers_populated": bool(rows), "rows": rows})
|
|
||||||
except Exception:
|
|
||||||
return _analytics_error("analytics/driver-behaviour")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analytics/fuel")
|
|
||||||
def analytics_fuel(
|
|
||||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
|
||||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
|
||||||
vehicle_number: str | None = None, driver: str | None = None,
|
|
||||||
):
|
|
||||||
start, end = _analytics_window(period, start_date, end_date)
|
|
||||||
clauses, params = _dim_filters(cost_centre, assigned_city, vehicle_number, driver)
|
|
||||||
params |= {"start": start, "end": end}
|
|
||||||
where = " AND ".join(["trip_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT vehicle_number, cost_centre, assigned_city,
|
|
||||||
round(sum(distance_km), 1) AS total_km,
|
|
||||||
round(sum(actual_fuel_l), 2) AS actual_fuel_l,
|
|
||||||
round(sum(estimated_fuel_l), 2) AS estimated_fuel_l,
|
|
||||||
count(*) FILTER (WHERE actual_fuel_l IS NOT NULL) AS trips_with_actual,
|
|
||||||
count(*) AS trips
|
|
||||||
FROM reporting.v_fuel_daily
|
|
||||||
WHERE {where}
|
|
||||||
GROUP BY vehicle_number, cost_centre, assigned_city
|
|
||||||
ORDER BY total_km DESC NULLS LAST
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT bool_or(actual_fuel_l IS NOT NULL) AS actual_available,
|
|
||||||
bool_or(estimated_fuel_l IS NOT NULL) AS estimated_available
|
|
||||||
FROM reporting.v_fuel_daily
|
|
||||||
WHERE {where}
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
flags = cur.fetchone() or {}
|
|
||||||
data_status = {
|
|
||||||
"actual_fuel_available": bool(flags.get("actual_available")),
|
|
||||||
"estimated_fuel_available": bool(flags.get("estimated_available")),
|
|
||||||
"notes": [
|
|
||||||
"actual_fuel_l comes from trips.fuel_consumed_l (/pushtripreport webhook).",
|
|
||||||
"estimated_fuel_l needs devices.fuel_100km set per vehicle "
|
|
||||||
"(currently NULL fleet-wide — see CLAUDE.md Open Items).",
|
|
||||||
"Fuel-cost monetisation is unavailable: ops.cost_rates was purged 2026-06-05.",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return _json({"window": {"start": str(start), "end": str(end)},
|
|
||||||
"data_status": data_status, "rows": rows})
|
|
||||||
except Exception:
|
|
||||||
return _analytics_error("analytics/fuel")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analytics/filters")
|
|
||||||
def analytics_filters():
|
|
||||||
# Same dropdown options the trips dashboard uses (drivers / cost_centres /
|
|
||||||
# cities / vehicles). Aliased so FleetOps has a single /analytics/* surface.
|
|
||||||
return fleet_filter_options()
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# deploy_dashboard_api_staging.sh — STAGING twin of ~/deploy_dashboard_api.sh
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Stands up a SECOND dashboard_api bridge for the staging umbrella
|
|
||||||
# (fleetapi.fivetitude.com). It mirrors the prod script but with four
|
|
||||||
# deliberate differences:
|
|
||||||
#
|
|
||||||
# 1. Container name dashboard_api_staging (prod: dashboard_api)
|
|
||||||
# 2. Port / Traefik 8891 + Host(fleetapi.fivetitude.com) (prod: 8890 + rahamafresh)
|
|
||||||
# 3. DB role READ-ONLY dashboard_ro DATABASE_URL — dedicated least-privilege
|
|
||||||
# role (scripts/bootstrap_dashboard_ro.sh); password read from
|
|
||||||
# ~/.dashboard_ro.pw (prod: the app's read/write DATABASE_URL).
|
|
||||||
# 4. Refresher OFF VTRIPS_REFRESH_INTERVAL_S=0 — prod owns the v_trips refresh;
|
|
||||||
# a read-only instance must never attempt REFRESH.
|
|
||||||
#
|
|
||||||
# Staging reads the SAME production DB (over the internal Docker network) as
|
|
||||||
# grafana_ro, so it is physically incapable of writing. See
|
|
||||||
# docs/STAGING_FLEETOPS_ARCHITECTURE.md §6.
|
|
||||||
#
|
|
||||||
# Like prod, this is a STANDALONE bridge container (NOT Coolify-managed): it
|
|
||||||
# reuses the webhook_receiver image + app network, bind-mounts the WIP API file,
|
|
||||||
# and an env/CORS change needs a container RECREATE (this script does that).
|
|
||||||
#
|
|
||||||
# Deploy:
|
|
||||||
# scp dashboard_api_rev.py kianiadee@twala.rahamafresh.com:~/dashboard_api_staging_rev.py
|
|
||||||
# scp deploy_dashboard_api_staging.sh kianiadee@twala.rahamafresh.com:~/
|
|
||||||
# ssh kianiadee@twala.rahamafresh.com 'bash ~/deploy_dashboard_api_staging.sh'
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
NAME=dashboard_api_staging
|
|
||||||
PORT=8891
|
|
||||||
MOUNT_DIR=/home/kianiadee/dashboard_api_staging
|
|
||||||
ENV_FILE="$MOUNT_DIR/dapi.staging.env"
|
|
||||||
STAGED_SRC=/home/kianiadee/dashboard_api_staging_rev.py
|
|
||||||
CORS='https://fleetnow.fivetitude.com,https://fleetops.fivetitude.com'
|
|
||||||
|
|
||||||
WH=$(docker ps --filter name=webhook_receiver --format "{{.Names}}" | head -1)
|
|
||||||
IMG=$(docker inspect "$WH" --format "{{.Image}}")
|
|
||||||
APPNET=$(docker inspect "$WH" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
|
|
||||||
echo "Reusing image $IMG on network $APPNET (from $WH)"
|
|
||||||
|
|
||||||
mkdir -p "$MOUNT_DIR"
|
|
||||||
# Stage a fresh copy only if one was scp'd to ~; otherwise keep the existing mount.
|
|
||||||
if [ -f "$STAGED_SRC" ]; then
|
|
||||||
mv -f "$STAGED_SRC" "$MOUNT_DIR/dashboard_api_rev.py"
|
|
||||||
fi
|
|
||||||
test -f "$MOUNT_DIR/dashboard_api_rev.py" \
|
|
||||||
|| { echo "ERROR: dashboard_api_rev.py missing in $MOUNT_DIR; scp it to ~/dashboard_api_staging_rev.py first"; exit 1; }
|
|
||||||
|
|
||||||
# Derive a READ-ONLY DATABASE_URL on the host (never printed): take the app's
|
|
||||||
# DATABASE_URL host:port/dbname and swap the credentials for dashboard_ro, whose
|
|
||||||
# password lives in the host-only 0600 file written by bootstrap_dashboard_ro.sh.
|
|
||||||
SRC_DB_URL=$(docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DATABASE_URL=//p' | head -1)
|
|
||||||
RO_PW=$(cat "${DASHBOARD_RO_PW_FILE:-$HOME/.dashboard_ro.pw}" 2>/dev/null || true)
|
|
||||||
[ -n "$SRC_DB_URL" ] || { echo "ERROR: DATABASE_URL not found in $WH env"; exit 1; }
|
|
||||||
[ -n "$RO_PW" ] || { echo "ERROR: ~/.dashboard_ro.pw missing — run bootstrap_dashboard_ro.sh first"; exit 1; }
|
|
||||||
HOSTPART="${SRC_DB_URL#*@}" # host:port/dbname[?params]
|
|
||||||
RO_DB_URL="postgresql://dashboard_ro:${RO_PW}@${HOSTPART}"
|
|
||||||
|
|
||||||
# Reuse the webhook env, stripping runtime noise AND anything we override below
|
|
||||||
# (DATABASE_URL -> read-only, CORS -> staging origins, refresher -> disabled).
|
|
||||||
docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
|
||||||
| grep -vE '^(PATH=|HOSTNAME=|HOME=|PWD=|TERM=|SHLVL=|_=|LANG=|GPG_KEY=|PYTHON_VERSION=|PYTHON_PIP_VERSION=|PYTHONUNBUFFERED=|DATABASE_URL=|DASHBOARD_CORS_ORIGINS=|VTRIPS_REFRESH_INTERVAL_S=)' \
|
|
||||||
> "$ENV_FILE"
|
|
||||||
{
|
|
||||||
echo "DATABASE_URL=${RO_DB_URL}"
|
|
||||||
echo "DASHBOARD_CORS_ORIGINS=${CORS}"
|
|
||||||
echo "VTRIPS_REFRESH_INTERVAL_S=0"
|
|
||||||
} >> "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
|
|
||||||
docker rm -f "$NAME" 2>/dev/null || true
|
|
||||||
docker run -d --name "$NAME" --restart unless-stopped \
|
|
||||||
--network "$APPNET" \
|
|
||||||
--env-file "$ENV_FILE" \
|
|
||||||
-v "$MOUNT_DIR/dashboard_api_rev.py:/app/dashboard_api_rev.py:ro" \
|
|
||||||
--label 'traefik.enable=true' \
|
|
||||||
--label 'traefik.docker.network=coolify' \
|
|
||||||
--label 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https' \
|
|
||||||
--label 'traefik.http.routers.http-0-fleetapi-staging.entryPoints=http' \
|
|
||||||
--label 'traefik.http.routers.http-0-fleetapi-staging.middlewares=redirect-to-https' \
|
|
||||||
--label 'traefik.http.routers.http-0-fleetapi-staging.rule=Host(`fleetapi.fivetitude.com`)' \
|
|
||||||
--label 'traefik.http.routers.https-0-fleetapi-staging.entryPoints=https' \
|
|
||||||
--label 'traefik.http.routers.https-0-fleetapi-staging.rule=Host(`fleetapi.fivetitude.com`)' \
|
|
||||||
--label 'traefik.http.routers.https-0-fleetapi-staging.tls=true' \
|
|
||||||
--label 'traefik.http.routers.https-0-fleetapi-staging.tls.certresolver=letsencrypt' \
|
|
||||||
--label "traefik.http.services.fleetapi-staging.loadbalancer.server.port=${PORT}" \
|
|
||||||
"$IMG" sh -c "uvicorn dashboard_api_rev:app --host 0.0.0.0 --port ${PORT} --workers 2"
|
|
||||||
|
|
||||||
docker network connect coolify "$NAME" 2>/dev/null || true
|
|
||||||
sleep 5
|
|
||||||
echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}"
|
|
||||||
echo "== CORS origins in effect =="; docker exec "$NAME" printenv DASHBOARD_CORS_ORIGINS
|
|
||||||
echo "== refresher (expect 0 = disabled) =="; docker exec "$NAME" printenv VTRIPS_REFRESH_INTERVAL_S
|
|
||||||
echo "== DB role (expect dashboard_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:<pw>@#"'
|
|
||||||
echo "== internal health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/health" 2>&1 | head
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
# Staging Environment & FleetOps Split — Architecture
|
|
||||||
|
|
||||||
**Status:** approved 2026-06-10 · **Owner:** kianiadee · **Audience:** both developers (mixed
|
|
||||||
technical/ops background — readable without prior context).
|
|
||||||
|
|
||||||
This document describes how we (a) introduce a **staging environment** under the
|
|
||||||
`fivetitude.com` umbrella so the production FleetNow map is never edited directly, and (b)
|
|
||||||
**split the product** into two surfaces: **FleetNow** (live tracking) and **FleetOps** (fleet
|
|
||||||
operations — fuel, analytics, KPIs).
|
|
||||||
|
|
||||||
> **No secrets here.** All connection values come from `.env` at runtime — see
|
|
||||||
> [`CONNECTIONS.md`](CONNECTIONS.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why this change
|
|
||||||
|
|
||||||
FleetNow (`fleetnow.rahamafresh.com`) is now the client's **production** map, so we can no
|
|
||||||
longer make feature changes or run tests directly against it. Separately, the client asked us
|
|
||||||
to separate **fleet tracking** from **fleet operations** (fuel management, analytics). That
|
|
||||||
gives us two needs:
|
|
||||||
|
|
||||||
1. A **staging environment** that mirrors production for safe development and testing.
|
|
||||||
2. A **new FleetOps surface** (`fleetops.rahamafresh.com`) distinct from the tracking map.
|
|
||||||
|
|
||||||
### Decisions on record
|
|
||||||
|
|
||||||
| Decision | Choice |
|
|
||||||
|---|---|
|
|
||||||
| Staging umbrella domain | **`fivetitude.com`** — DNS is a **wildcard** (`*.fivetitude.com` → the VPS), so staging subdomains need **no per-host DNS records**, only Traefik/Coolify host rules |
|
|
||||||
| FleetOps surface | **New custom SPA** (FleetNow-style), consuming an extended `dashboard_api` — *not* Grafana |
|
|
||||||
| Staging data backing | **Full stack reading the shared production `reporting.*` read-layer** (read-only, no DB duplication) |
|
|
||||||
| Deploy mechanism | **Forgejo → Coolify webhook deploys** across all Coolify apps (replaces polling/manual) |
|
|
||||||
| FleetOps web server | **Caddy** (greenfield) for the cleaner Caddyfile + native `{env.*}` API-base injection. Chosen for config ergonomics, **not** TLS — Traefik already terminates TLS. Existing nginx SPAs stay as-is (mixed fleet until FleetNow's next touch) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Target topology
|
|
||||||
|
|
||||||
| Environment | FleetNow (tracking) | FleetOps (operations) | Read-API |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Production** (`rahamafresh.com`) | `fleetnow.rahamafresh.com` — *frozen* | `fleetops.rahamafresh.com` — **new** | `fleetapi.rahamafresh.com` |
|
|
||||||
| **Staging** (`fivetitude.com`) | `fleetnow.fivetitude.com` | `fleetops.fivetitude.com` | `fleetapi.fivetitude.com` |
|
|
||||||
|
|
||||||
- Every product surface (FleetNow/FleetOps × prod/staging) is a **Coolify app** (Dockerfile →
|
|
||||||
static web server), one app per cell, each bound to its own git branch. **FleetOps uses
|
|
||||||
Caddy** (clean Caddyfile, native `{env.*}` for the per-env API base); the existing FleetNow
|
|
||||||
and the two legacy SPAs remain on **nginx**. Both are plain `:80` file servers — **Traefik
|
|
||||||
terminates TLS**, so Caddy's auto-HTTPS is intentionally unused.
|
|
||||||
- The read-API (`dashboard_api`) is a **standalone Traefik-labelled bridge container** — *not*
|
|
||||||
Coolify-managed. It is deployed by a host script and gains a **second staging instance**.
|
|
||||||
- **Staging reads the same production TimescaleDB** over the internal Docker network, but as a
|
|
||||||
**read-only role** with the materialized-view refresher **disabled** (see §6).
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────── VPS (31.97.44.246) ───────────────────────────┐
|
|
||||||
PRODUCTION │ │
|
|
||||||
fleetnow.raha… ──────┼─► Coolify app (FleetNow:main) ─┐ │
|
|
||||||
fleetops.raha… ──────┼─► Coolify app (FleetOps:main) ─┼─► fleetapi.rahamafresh.com (bridge:8890) │
|
|
||||||
│ │ │ app role (rw) + refresher │
|
|
||||||
│ │ ▼ │
|
|
||||||
STAGING │ │ ┌──────────────────────────┐ │
|
|
||||||
fleetnow.fivet… ─────┼─► Coolify app (FleetNow:staging)┼──►│ tracksolid_db │ │
|
|
||||||
fleetops.fivet… ─────┼─► Coolify app (FleetOps:staging)┼─┐ │ reporting.* / v_trips MV │ │
|
|
||||||
│ │ └►│ tracksolid.v_* │ │
|
|
||||||
│ fleetapi.fivetitude.com ──────┘ └──────────────────────────┘ │
|
|
||||||
│ (bridge:8891, read-only role, refresher OFF) │
|
|
||||||
└───────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The two read-API instances
|
|
||||||
|
|
||||||
The API code is `dashboard_api_rev.py` **in this repo**. Production is deployed by
|
|
||||||
`~/deploy_dashboard_api.sh` (bind-mounts `~/dashboard_api/dashboard_api_rev.py`, **port 8890**,
|
|
||||||
Traefik host `fleetapi.rahamafresh.com`). Staging mirrors it:
|
|
||||||
|
|
||||||
| | Production | Staging |
|
|
||||||
|---|---|---|
|
|
||||||
| Host rule | `fleetapi.rahamafresh.com` | `fleetapi.fivetitude.com` |
|
|
||||||
| Port | 8890 | **8891** |
|
|
||||||
| Code mount | `~/dashboard_api/` | `~/dashboard_api_staging/` (WIP checkout) |
|
|
||||||
| Deploy script | `~/deploy_dashboard_api.sh` | **`deploy_dashboard_api_staging.sh`** (checked into this repo) |
|
|
||||||
| DB role | app role (read/write) | **read-only** `dashboard_ro` (dedicated) |
|
|
||||||
| `v_trips` refresher | **owns it** | **disabled** |
|
|
||||||
| CORS origins | `fleetnow.rahamafresh.com`, `fleetintelligence.…`, `liveposition.…`, **+ `fleetops.rahamafresh.com`** | `fleetnow.fivetitude.com`, `fleetops.fivetitude.com` |
|
|
||||||
|
|
||||||
> **CORS must be set unconditionally** in the deploy script (strip any inherited value) — this
|
|
||||||
> is the [FIX-D03](../CLAUDE.md) lesson. Env/CORS changes require a container **recreate**, not
|
|
||||||
> a restart.
|
|
||||||
|
|
||||||
### Analytics endpoints (FleetOps)
|
|
||||||
|
|
||||||
FleetOps consumes new **read-only** routes added to `dashboard_api_rev.py`, reusing the
|
|
||||||
existing psycopg2 pool (`ts_shared_rev.py`), the Content-Type body-parse pattern (FIX-D01), and
|
|
||||||
the JSONB/GeoJSON return style of the existing `/webhook/*` routes:
|
|
||||||
|
|
||||||
| Route | Backed by |
|
|
||||||
|---|---|
|
|
||||||
| `GET /analytics/fleet-summary` | `reporting.v_daily_summary` / `v_weekly_summary` / `v_monthly_summary` + `v_daily_cost_centre` |
|
|
||||||
| `GET /analytics/utilisation` | derived from the `reporting` summaries (idle_pct, km/day) |
|
|
||||||
| `GET /analytics/driver-behaviour` | `tracksolid.v_driver_aggregates_daily` |
|
|
||||||
| `GET /analytics/fuel` | `reporting.v_fuel_daily` (migration 17 — wraps `v_trips.fuel_consumed_l` + `devices.fuel_100km`) — **data-gated** (returns "needs data" flags until populated) |
|
|
||||||
| `GET /analytics/filters` | `reporting.v_filter_*` (alias of `GET /webhook/fleet-dashboard`) |
|
|
||||||
|
|
||||||
Aggregations that aren't thin wrappers get a **new numbered migration** — never edit an applied
|
|
||||||
one. The fuel roll-up ships as `migrations/17_fleetops_fuel_view.sql` (the live migration head was
|
|
||||||
**16**, not 13 as older docs imply; 17 + 18 are now applied). `dashboard_ro` reads `v_fuel_daily`
|
|
||||||
via the schema-wide `SELECT` grant in `scripts/dashboard_ro_role.sql`.
|
|
||||||
|
|
||||||
> **Reuse the existing reporting layer.** The analytics building blocks are `reporting.*`
|
|
||||||
> (migrations 11/14) and the surviving `tracksolid.v_*` views (migration 07). The `ops.*` and
|
|
||||||
> `dwh_gold.*` schemas were **purged 2026-06-05** (migrations 12/13) — do **not** reference
|
|
||||||
> `ops.*`, `dwh_gold.*`, `v_utilisation_daily`, or `v_sla_inflight`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Deploy & promotion (Forgejo → Coolify webhooks)
|
|
||||||
|
|
||||||
All Coolify apps move from polling/manual to **webhook-driven** deploys. For each app, take
|
|
||||||
Coolify's per-app **deploy webhook URL** (+ token) and register it as a **push webhook in the
|
|
||||||
matching Forgejo repo**, scoped to the bound branch.
|
|
||||||
|
|
||||||
**Promotion model** (both FleetNow and FleetOps):
|
|
||||||
|
|
||||||
```
|
|
||||||
feature branch ──merge──► staging ──(Forgejo webhook)──► Coolify deploys *.fivetitude.com
|
|
||||||
│ validate
|
|
||||||
main ◄──merge──────────────────────┘
|
|
||||||
│
|
|
||||||
└──(Forgejo webhook)──► Coolify deploys *.rahamafresh.com (prod)
|
|
||||||
```
|
|
||||||
|
|
||||||
Production is touched **only** by a merge to `main`. That branch discipline is what satisfies
|
|
||||||
"no direct changes to production FleetNow."
|
|
||||||
|
|
||||||
> **Exception:** the `dashboard_api` bridge is **not** Coolify-managed and does **not** deploy
|
|
||||||
> via Forgejo webhook — it is deployed by its host script (`deploy_dashboard_api*.sh`). The API
|
|
||||||
> code's source of truth is this repo; the staging instance bind-mounts a WIP checkout so new
|
|
||||||
> endpoints are validated on `fleetapi.fivetitude.com` before the file is promoted to
|
|
||||||
> `~/dashboard_api/` on prod.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. FleetOps SPA (new repo)
|
|
||||||
|
|
||||||
- **Remote:** `https://repo.rahamafresh.com/kianiadee/fleetops.git`
|
|
||||||
- **Local working copy:** `~/Downloads/projects/15_fleetops` (scaffolded from empty)
|
|
||||||
- **Shape:** FleetNow-style deploy flow, but **Dockerfile → Caddy** via Coolify; branded for
|
|
||||||
operations/analytics. The Caddyfile is a ~5-line SPA server (`try_files {path} /index.html`,
|
|
||||||
`encode zstd gzip`) on `:80` behind Traefik.
|
|
||||||
- **API base URL is build/runtime configurable** via Caddy's native `{env.API_BASE}`
|
|
||||||
substitution (set per Coolify app): staging → `fleetapi.fivetitude.com`, prod →
|
|
||||||
`fleetapi.rahamafresh.com`.
|
|
||||||
- **FleetNow** gets the same treatment in *its own* repo: a `staging` branch and a
|
|
||||||
parameterized API base URL (assumed currently hardcoded to `fleetapi.rahamafresh.com`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Safety — staging on the shared production read-layer
|
|
||||||
|
|
||||||
Staging hits the **production database**, so isolation is enforced at the **DB-role level**,
|
|
||||||
not by a separate DB:
|
|
||||||
|
|
||||||
- The staging `dashboard_api` connects as a **dedicated read-only role `dashboard_ro`**
|
|
||||||
(`scripts/dashboard_ro_role.sql` + `scripts/bootstrap_dashboard_ro.sh`). It grants exactly
|
|
||||||
what the API reads — `SELECT` on `reporting.*` + `tracksolid.*`, an explicit `SELECT` on the
|
|
||||||
`reporting.v_trips` **materialized view** (matviews aren't covered by `GRANT ... ON ALL
|
|
||||||
TABLES`), `EXECUTE` on the `reporting.fn_*` map functions, and `ALTER DEFAULT PRIVILEGES` so
|
|
||||||
future objects are auto-readable ("dynamic"). No write/REFRESH privilege, so accidental writes
|
|
||||||
are impossible. The password is generated on the host into `~/.dashboard_ro.pw` (0600), never
|
|
||||||
in the repo. **Two-stage:** stage 1 backs the *staging* bridge (done); stage 2 migrates the
|
|
||||||
*live prod* `fleetapi.rahamafresh.com` connection off the app role onto `dashboard_ro` (which
|
|
||||||
already grants the full read surface, incl. the live `fn_*` path).
|
|
||||||
- The **`reporting.v_trips` materialized-view refresher is disabled on staging** — production
|
|
||||||
owns it. The refresher needs write perms and is already pg-advisory-lock guarded (key
|
|
||||||
`920_145`, FIX-D02); a read-only staging role would only log errors, so disable it explicitly
|
|
||||||
(refresh interval `0` / env guard).
|
|
||||||
- New `/analytics/*` queries stay backed by the **indexed `reporting.*` views / matview**, not
|
|
||||||
raw hypertable scans, so staging traffic doesn't load the prod DB.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Phased rollout
|
|
||||||
|
|
||||||
Ordered by dependency and risk — prove the foundation and the deploy pipeline first; touch the
|
|
||||||
client's production domains **last**.
|
|
||||||
|
|
||||||
| Phase | Scope | Exit criterion |
|
|
||||||
|---|---|---|
|
|
||||||
| **0 — Foundation** | This document; provision the read-only `dashboard_ro` role (**done**); migrate all Coolify apps to Forgejo webhook deploys (**pending**) | `dashboard_ro` can `SELECT` `reporting.*` + `tracksolid.*` + `v_trips` and nothing else; every Coolify app redeploys via webhook |
|
|
||||||
| **1 — Staging backbone** | Staging `dashboard_api` bridge (`deploy_dashboard_api_staging.sh`, 8891, `fleetapi.fivetitude.com`, read-only, refresher off, staging CORS) | `curl https://fleetapi.fivetitude.com/health` ok; verifiably read-only; no staging rows in `reporting.refresh_log` |
|
|
||||||
| **2 — FleetNow staging** | FleetNow repo: `staging` branch + parameterized API base + `fleetnow.fivetitude.com` Coolify app | Renders against staging API; `staging` push deploys staging only, `main` merge deploys prod only; prod FleetNow untouched |
|
|
||||||
| **3 — FleetOps backend** | `/analytics/*` endpoints in `dashboard_api_rev.py` + `migrations/17_fleetops_fuel_view.sql`; refresher made disable-able (`VTRIPS_REFRESH_INTERVAL_S<=0`); tested on the staging API | Every route returns correct shape on `fleetapi.fivetitude.com`; fuel route returns "needs data" flags |
|
|
||||||
| **4 — FleetOps SPA** | Scaffold `15_fleetops` (git init + remote + SPA/Dockerfile); `fleetops.fivetitude.com` Coolify app | Renders fuel/analytics/utilisation/driver panels from staging endpoints; CORS clean |
|
|
||||||
| **5 — Production cutover** | Promote API to prod + prod CORS add; `fleetops.rahamafresh.com` Coolify app; prod DNS record; update `CLAUDE.md` / `CONNECTIONS.md` / `PLATFORM_OVERVIEW.html` | FleetOps live on prod; prod FleetNow/API otherwise unchanged; docs current |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Verification checklist
|
|
||||||
|
|
||||||
1. **Staging API up:** `curl -f https://fleetapi.fivetitude.com/health` → `{status: ok}`;
|
|
||||||
resolve the container via `docker ps --filter name=dashboard_api_staging`.
|
|
||||||
2. **Read-only enforced:** a write attempt from the staging role fails; **no**
|
|
||||||
`reporting.refresh_log` rows carry a staging source.
|
|
||||||
3. **Analytics:** hit each `/analytics/*` on staging, diff the JSON against the underlying view
|
|
||||||
output via `docker exec $DB psql`; fuel returns "needs data" flags.
|
|
||||||
4. **CORS:** browser-load `fleetops.fivetitude.com` and `fleetnow.fivetitude.com`; XHRs to
|
|
||||||
`fleetapi.fivetitude.com` succeed; prod `fleetops.rahamafresh.com` reaches the prod API.
|
|
||||||
5. **Webhook promotion:** push to `staging` → Forgejo webhook fires → **only** the
|
|
||||||
`*.fivetitude.com` app redeploys (check Coolify deploy log + Forgejo webhook delivery);
|
|
||||||
merge to `main` → only the `*.rahamafresh.com` app redeploys.
|
|
||||||
6. **Prod FleetNow untouched:** prod `fleetnow`/`fleetapi` containers not recreated except the
|
|
||||||
intentional prod-CORS add.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Risks & open items
|
|
||||||
|
|
||||||
- **FleetNow API-base** parameterization assumes it's currently hardcoded — confirm in that repo.
|
|
||||||
- **Shared-DB load:** staging traffic is light, but watch the prod DB if staging analytics
|
|
||||||
queries get heavy; the read-only role + indexed views are the guardrails.
|
|
||||||
- **Fuel analytics are data-blocked:** `devices.fuel_100km` is NULL fleet-wide and the
|
|
||||||
`/pushoil` + `/pushobd` webhooks aren't registered, so FleetOps fuel views ship as scaffold
|
|
||||||
until those Open Items (CLAUDE.md §10) are closed.
|
|
||||||
- **Naming trap:** `stage.rahamafresh.com` is the *production* host alias (a legacy name). Keep
|
|
||||||
all real staging under `*.fivetitude.com` to avoid confusion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Related: [`CONNECTIONS.md`](CONNECTIONS.md) · [`PLATFORM_OVERVIEW.html`](PLATFORM_OVERVIEW.html) ·
|
|
||||||
[`DWH_PIPELINE.md`](DWH_PIPELINE.md) · root `CLAUDE.md` (§3 map dashboards, §7 fix history).*
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
-- 17_fleetops_fuel_view.sql
|
|
||||||
-- FleetOps fuel roll-up source: reporting.v_fuel_daily.
|
|
||||||
--
|
|
||||||
-- Backs GET /analytics/fuel in dashboard_api_rev.py (the FleetOps SPA). It pairs
|
|
||||||
-- ACTUAL fuel (trips.fuel_consumed_l, from the /pushtripreport webhook) with an
|
|
||||||
-- ESTIMATED figure (distance_km * devices.fuel_100km / 100) so the SPA can show
|
|
||||||
-- both and flag the gap.
|
|
||||||
--
|
|
||||||
-- Why a view (not a direct join in the API): it encapsulates the
|
|
||||||
-- reporting.v_trips -> tracksolid.devices join so the read-only staging role only
|
|
||||||
-- needs SELECT on this one reporting.* object, not on tracksolid.devices. It reuses
|
|
||||||
-- the same per-trip grain + is_meaningful_route filter as the other reporting
|
|
||||||
-- summaries (migration 11), and the same imei key v_trips already exposes.
|
|
||||||
--
|
|
||||||
-- Data state (2026-06-10): devices.fuel_100km is NULL fleet-wide and the /pushoil
|
|
||||||
-- + /pushobd webhooks are unregistered, so estimated_fuel_l is NULL today and
|
|
||||||
-- actual_fuel_l is sparse. The view is correct now and fills in as data lands —
|
|
||||||
-- the API surfaces availability flags rather than faking numbers. Fuel-cost
|
|
||||||
-- monetisation is intentionally absent: ops.cost_rates was purged 2026-06-05
|
|
||||||
-- (migration 12).
|
|
||||||
--
|
|
||||||
-- CREATE OR REPLACE + guarded grant -> safe to re-apply.
|
|
||||||
|
|
||||||
SET search_path = reporting, tracksolid, public;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW reporting.v_fuel_daily AS
|
|
||||||
SELECT t.trip_date,
|
|
||||||
t.vehicle_number,
|
|
||||||
t.cost_centre,
|
|
||||||
t.assigned_city,
|
|
||||||
t.assigned_driver,
|
|
||||||
t.imei,
|
|
||||||
t.distance_km,
|
|
||||||
t.fuel_consumed_l AS actual_fuel_l,
|
|
||||||
CASE
|
|
||||||
WHEN d.fuel_100km IS NOT NULL AND t.distance_km IS NOT NULL
|
|
||||||
THEN round(t.distance_km * d.fuel_100km / 100.0, 3)
|
|
||||||
ELSE NULL::numeric
|
|
||||||
END AS estimated_fuel_l
|
|
||||||
FROM reporting.v_trips t
|
|
||||||
LEFT JOIN tracksolid.devices d ON d.imei = t.imei
|
|
||||||
WHERE t.is_meaningful_route;
|
|
||||||
|
|
||||||
COMMENT ON VIEW reporting.v_fuel_daily IS
|
|
||||||
'Per-trip fuel: actual (trips.fuel_consumed_l) vs estimated (distance_km * devices.fuel_100km/100). '
|
|
||||||
'Source for dashboard_api GET /analytics/fuel. Encapsulates the v_trips->devices join so the '
|
|
||||||
'read-only staging role needs SELECT only on this view. fuel_100km is NULL fleet-wide as of 2026-06-10.';
|
|
||||||
|
|
||||||
-- ── grants (guarded: roles may not exist on a fresh DB) ───────────────────────
|
|
||||||
DO $grants$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
|
||||||
GRANT USAGE ON SCHEMA reporting TO grafana_ro;
|
|
||||||
GRANT SELECT ON reporting.v_fuel_daily TO grafana_ro;
|
|
||||||
END IF;
|
|
||||||
END $grants$;
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- 18_grant_reporting_ro.sql
|
|
||||||
-- Read-only access to the reporting.* layer for grafana_ro.
|
|
||||||
--
|
|
||||||
-- grafana_ro is the read-only role the STAGING dashboard_api connects as (it reads
|
|
||||||
-- the prod DB but must be physically unable to write — see
|
|
||||||
-- docs/STAGING_FLEETOPS_ARCHITECTURE.md §6). It already reads tracksolid.* (Grafana
|
|
||||||
-- + the migration-07 analytics views), but was never granted SELECT on the
|
|
||||||
-- reporting.* map/analytics layer (migration 11) — the prod dashboard_api connects
|
|
||||||
-- as the app/superuser role, so the gap went unnoticed until the read-only staging
|
|
||||||
-- instance hit "permission denied for view v_filter_drivers / v_daily_summary".
|
|
||||||
--
|
|
||||||
-- This grants USAGE + SELECT across reporting.* and sets DEFAULT PRIVILEGES so any
|
|
||||||
-- future reporting view/table is auto-readable by grafana_ro (no re-grant needed).
|
|
||||||
-- Read-only only: no INSERT/UPDATE/DELETE, so grafana_ro still cannot write or
|
|
||||||
-- REFRESH. Guarded + idempotent -> safe to re-apply.
|
|
||||||
|
|
||||||
DO $grants$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
|
||||||
GRANT USAGE ON SCHEMA reporting TO grafana_ro;
|
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO grafana_ro; -- includes views
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA reporting GRANT SELECT ON TABLES TO grafana_ro;
|
|
||||||
END IF;
|
|
||||||
END $grants$;
|
|
||||||
|
|
@ -40,8 +40,6 @@ MIGRATIONS = [
|
||||||
"14_fleet_segment_and_vehicles_view.sql", # reporting.fn_fleet_segment + reporting.v_vehicles roster
|
"14_fleet_segment_and_vehicles_view.sql", # reporting.fn_fleet_segment + reporting.v_vehicles roster
|
||||||
"15_map_exclude_cost_centres.sql", # hide personal/management/mtn vehicles from the live map
|
"15_map_exclude_cost_centres.sql", # hide personal/management/mtn vehicles from the live map
|
||||||
"16_live_feed_vehicle_type.sql", # add vehicle_type + fleet_segment to fn_live_positions feed
|
"16_live_feed_vehicle_type.sql", # add vehicle_type + fleet_segment to fn_live_positions feed
|
||||||
"17_fleetops_fuel_view.sql", # reporting.v_fuel_daily — FleetOps GET /analytics/fuel source
|
|
||||||
"18_grant_reporting_ro.sql", # grant SELECT on reporting.* to grafana_ro (staging read-only role)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── Tables that must exist before the service is allowed to start ─────────────
|
# ── Tables that must exist before the service is allowed to start ─────────────
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# bootstrap_dashboard_ro.sh — create/refresh the dashboard_ro read-only role.
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Run ON THE HOST. Generates a strong password into ~/.dashboard_ro.pw (0600) on
|
|
||||||
# first run (reused thereafter), then applies scripts/dashboard_ro_role.sql to the
|
|
||||||
# prod DB as the postgres superuser. The password is NEVER printed and never
|
|
||||||
# leaves the host — the staging deploy script reads the same ~/.dashboard_ro.pw.
|
|
||||||
#
|
|
||||||
# Deploy:
|
|
||||||
# scp scripts/dashboard_ro_role.sql scripts/bootstrap_dashboard_ro.sh \
|
|
||||||
# kianiadee@twala.rahamafresh.com:~/
|
|
||||||
# ssh kianiadee@twala.rahamafresh.com 'bash ~/bootstrap_dashboard_ro.sh'
|
|
||||||
#
|
|
||||||
# Idempotent: re-running rotates nothing unless ~/.dashboard_ro.pw is deleted
|
|
||||||
# first (then it generates + sets a fresh password and you must redeploy the API).
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PW_FILE="${DASHBOARD_RO_PW_FILE:-$HOME/.dashboard_ro.pw}"
|
|
||||||
SQL_FILE="${1:-$HOME/dashboard_ro_role.sql}"
|
|
||||||
|
|
||||||
test -f "$SQL_FILE" || { echo "ERROR: role SQL not found at $SQL_FILE (scp scripts/dashboard_ro_role.sql to ~ first)"; exit 1; }
|
|
||||||
|
|
||||||
if [ ! -s "$PW_FILE" ]; then
|
|
||||||
( umask 077; openssl rand -hex 24 > "$PW_FILE" )
|
|
||||||
chmod 600 "$PW_FILE"
|
|
||||||
echo "Generated new dashboard_ro password -> $PW_FILE (0600)"
|
|
||||||
else
|
|
||||||
echo "Reusing existing dashboard_ro password from $PW_FILE"
|
|
||||||
fi
|
|
||||||
PW=$(cat "$PW_FILE")
|
|
||||||
|
|
||||||
DB=$(docker ps --filter name=timescale_db --format "{{.Names}}" | head -1)
|
|
||||||
[ -n "$DB" ] || { echo "ERROR: timescale_db container not found"; exit 1; }
|
|
||||||
|
|
||||||
echo "Applying dashboard_ro role DDL to $DB as postgres ..."
|
|
||||||
docker exec -i "$DB" psql -U postgres -d tracksolid_db -v ON_ERROR_STOP=1 -v ro_pw="$PW" < "$SQL_FILE"
|
|
||||||
echo "dashboard_ro ready (password not printed). Now (re)run deploy_dashboard_api_staging.sh."
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
-- dashboard_ro_role.sql — dedicated read-only LOGIN role for dashboard_api.
|
|
||||||
--
|
|
||||||
-- Run as the postgres SUPERUSER (CREATE ROLE), NOT via run_migrations.py (which
|
|
||||||
-- connects as the app role and may lack CREATEROLE). Apply with
|
|
||||||
-- scripts/bootstrap_dashboard_ro.sh, which supplies the password as the psql
|
|
||||||
-- variable :ro_pw from a host-only 0600 file — so no secret lives in this repo.
|
|
||||||
--
|
|
||||||
-- Purpose: a least-privilege role that can serve the FULL dashboard_api read
|
|
||||||
-- surface, so it backs BOTH the staging instance now (stage 1) AND the live prod
|
|
||||||
-- connection later (stage 2 — migrate fleetapi.rahamafresh.com off the app role).
|
|
||||||
-- It therefore grants exactly what the API reads:
|
|
||||||
-- * SELECT on reporting.* and tracksolid.* (tables + views)
|
|
||||||
-- * SELECT on the reporting.v_trips MATERIALIZED VIEW — matviews are NOT
|
|
||||||
-- covered by GRANT ... ON ALL TABLES, so it must be named explicitly
|
|
||||||
-- * EXECUTE on the reporting.fn_* map functions (fn_live_positions, etc.)
|
|
||||||
-- * DEFAULT PRIVILEGES so future objects created by the migration role are
|
|
||||||
-- auto-readable ("dynamic" — no re-grant when we add views)
|
|
||||||
-- Read-only: no INSERT/UPDATE/DELETE and not the matview owner, so dashboard_ro
|
|
||||||
-- can never write or REFRESH. Idempotent -> safe to re-apply (also rotates pw).
|
|
||||||
|
|
||||||
\set ON_ERROR_STOP on
|
|
||||||
|
|
||||||
DO $role$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
|
|
||||||
CREATE ROLE dashboard_ro LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
|
||||||
END IF;
|
|
||||||
END $role$;
|
|
||||||
|
|
||||||
ALTER ROLE dashboard_ro WITH LOGIN PASSWORD :'ro_pw';
|
|
||||||
|
|
||||||
GRANT CONNECT ON DATABASE tracksolid_db TO dashboard_ro;
|
|
||||||
GRANT USAGE ON SCHEMA reporting, tracksolid TO dashboard_ro;
|
|
||||||
|
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO dashboard_ro; -- tables + views
|
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA tracksolid TO dashboard_ro; -- tables + views
|
|
||||||
GRANT SELECT ON reporting.v_trips TO dashboard_ro; -- MATERIALIZED VIEW (not in ALL TABLES)
|
|
||||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO dashboard_ro;
|
|
||||||
|
|
||||||
-- "dynamic": future objects created by the migration role (tracksolid_owner)
|
|
||||||
-- are auto-granted. NOTE: matviews are still never covered — a new matview needs
|
|
||||||
-- its own explicit GRANT SELECT (as above for v_trips).
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT SELECT ON TABLES TO dashboard_ro;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA tracksolid GRANT SELECT ON TABLES TO dashboard_ro;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT EXECUTE ON FUNCTIONS TO dashboard_ro;
|
|
||||||
Loading…
Reference in a new issue