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.extras
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
|
@ -69,10 +68,6 @@ _ALLOWED_ORIGINS = [
|
|||
# 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.
|
||||
_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_LOCK_KEY = 920_145 # arbitrary, stable advisory-lock key for this job
|
||||
|
||||
|
|
@ -124,26 +119,17 @@ async def _refresh_loop():
|
|||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
refresher = None
|
||||
if _REFRESH_INTERVAL_S > 0:
|
||||
log.info(
|
||||
"Dashboard API starting (v1.2). Origins=%s. v_trips refresh every %ss.",
|
||||
_ALLOWED_ORIGINS, _REFRESH_INTERVAL_S,
|
||||
)
|
||||
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,
|
||||
)
|
||||
log.info(
|
||||
"Dashboard API starting (v1.1). Origins=%s. v_trips refresh every %ss.",
|
||||
_ALLOWED_ORIGINS, _REFRESH_INTERVAL_S,
|
||||
)
|
||||
refresher = asyncio.create_task(_refresh_loop())
|
||||
yield
|
||||
if refresher is not None:
|
||||
refresher.cancel()
|
||||
try:
|
||||
await refresher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
refresher.cancel()
|
||||
try:
|
||||
await refresher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
close_pool()
|
||||
|
||||
|
||||
|
|
@ -336,288 +322,3 @@ async def fleet_trips(request: Request):
|
|||
return JSONResponse(
|
||||
{"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
|
||||
"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
|
||||
"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 ─────────────
|
||||
|
|
|
|||
|
|
@ -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