Overhaul the Tickets tab into the documented INC operations dashboard,
backed by the new GET /webhook/inc-dashboard endpoint (reporting.fn_inc_dashboard):
- Filterbar (cluster / status / window: today|week|month|custom)
- Metric strip: open now, closed in window, open/closed SLA breakdown,
avg MTTR, closures/day + freshness
- Live map: open INC coloured by SLA state, dimmed closed overlay,
FleetNow vehicle markers, layer toggles + SLA legend
- By-status / by-cluster tables + daily closures chart
- Data load decoupled from the basemap so the dashboard renders even if
WebGL/map init is slow or fails
Removes the old combined INC/CRQ map and the /webhook/tickets call (CRQ deferred).
Adds docs/tickets-inc-{overhaul-plan,implementation-guide}.{md,html}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
11 KiB
Markdown
240 lines
11 KiB
Markdown
# FleetOps Tickets → INC Operations Dashboard — Implementation Guide
|
||
|
||
A step-by-step execution guide for replacing the combined INC/CRQ Tickets map in the
|
||
FleetOps SPA with the documented **INC operations dashboard** (open layer + windowed
|
||
closed overlay + SLA states + metric cards). CRQ is deferred.
|
||
|
||
> Companion to the higher-level **`tickets-inc-overhaul-plan.md`** in this folder.
|
||
> This guide is the actionable runbook.
|
||
|
||
---
|
||
|
||
## 0. Architecture & data flow
|
||
|
||
```
|
||
FleetOps SPA (15_fleetops/src/index.html)
|
||
│ GET /webhook/inc-dashboard?cluster=&status=&window=&from=&to=
|
||
▼
|
||
dashboard_api (tracksolid_timescale_grafana_prod/dashboard_api_rev.py)
|
||
│ SELECT reporting.fn_inc_dashboard(p_cluster,p_status,p_window,p_from,p_to)
|
||
▼
|
||
tracksolid_db → reporting.fn_inc_dashboard → tickets.inc / tickets.inc_open_sla
|
||
|
||
(vehicles overlaid separately: SPA → GET /webhook/live-positions → FleetNow)
|
||
```
|
||
|
||
- **Staging API**: `https://fleetapi.fivetitude.com` (read-only `dashboard_ro` role,
|
||
reads the same prod DB).
|
||
- **DB**: `tracksolid_db` on `twala.rahamafresh.com:5433` (direct psql/psycopg2
|
||
connection available via the write `DATABASE_URL`).
|
||
|
||
---
|
||
|
||
## 1. Prerequisites & access
|
||
|
||
| Need | Status / how |
|
||
|---|---|
|
||
| Write `DATABASE_URL` to `tracksolid_db` | Provided by user; export as `DATABASE_URL` (do **not** commit). |
|
||
| Python + psycopg2 | Use `16_fleettickets/.venv`. |
|
||
| Deploy access to staging host | scp + `ssh kianiadee@twala.rahamafresh.com` (SSH config entry exists). |
|
||
| Source repos | `15_fleetops` (SPA), `tracksolid_timescale_grafana_prod` (API), `16_fleettickets` (migrations/docs). |
|
||
|
||
---
|
||
|
||
## Phase A — API endpoint (do this first)
|
||
|
||
### Step A1 — Check whether `reporting.fn_inc_dashboard` is already deployed
|
||
|
||
```bash
|
||
cd ~/Downloads/projects/16_fleettickets
|
||
source .venv/bin/activate
|
||
export DATABASE_URL='postgres://…@twala.rahamafresh.com:5433/tracksolid_db' # provided
|
||
|
||
python - <<'PY'
|
||
import os, psycopg2
|
||
c = psycopg2.connect(os.environ["DATABASE_URL"]); cur = c.cursor()
|
||
cur.execute("SELECT filename FROM tickets.schema_migrations ORDER BY filename")
|
||
print("applied migrations:", [r[0] for r in cur.fetchall()])
|
||
cur.execute("SELECT to_regprocedure('reporting.fn_inc_dashboard(text,text,text,timestamptz,timestamptz)')")
|
||
print("fn_inc_dashboard:", cur.fetchone()[0])
|
||
PY
|
||
```
|
||
|
||
- If `fn_inc_dashboard` prints a signature → **skip A2**, go to A3.
|
||
- If it prints `None` → run A2.
|
||
|
||
### Step A2 — Apply migrations (idempotent, ledgered)
|
||
|
||
```bash
|
||
# Still in 16_fleettickets with DATABASE_URL exported
|
||
python run_migrations.py
|
||
```
|
||
|
||
- Applies any unapplied `migrations/*.sql` in order; already-applied (01–08) are
|
||
**skipped**. Expected new: `09_inc_dashboard_fn.sql` (and `10_inc_history_capture.sql`
|
||
if not yet present). All migrations are `CREATE OR REPLACE` / `IF NOT EXISTS`.
|
||
- Sanity check the function returns valid JSON:
|
||
|
||
```bash
|
||
python - <<'PY'
|
||
import os, json, psycopg2
|
||
c = psycopg2.connect(os.environ["DATABASE_URL"]); cur = c.cursor()
|
||
cur.execute("SELECT reporting.fn_inc_dashboard()")
|
||
d = cur.fetchone()[0]
|
||
print("keys:", list(d.keys()))
|
||
print("window:", d["window"])
|
||
print("open feats:", len(d["open"]["features"]), " closed feats:", len(d["closed"]["features"]))
|
||
print("metrics.open_now:", d["metrics"]["open_now"], " closed_in_window:", d["metrics"]["closed_in_window"])
|
||
PY
|
||
```
|
||
|
||
### Step A3 — Add the `/webhook/inc-dashboard` handler
|
||
|
||
File: `tracksolid_timescale_grafana_prod/dashboard_api_rev.py`. Mirror the existing
|
||
`tickets()` handler (`:275`). Reuse `get_conn`, `_clean`, `log`.
|
||
|
||
1. Add `Query` to the FastAPI import (line ~46):
|
||
```python
|
||
from fastapi import FastAPI, Request, Query
|
||
```
|
||
2. Add the handler (place near the `tickets()` endpoint):
|
||
```python
|
||
_INC_WINDOWS = {"today", "week", "month", "custom"}
|
||
|
||
@app.get("/webhook/inc-dashboard")
|
||
def inc_dashboard(
|
||
cluster: str | None = None,
|
||
status: str | None = None,
|
||
window: str = "today",
|
||
from_: str | None = Query(None, alias="from"),
|
||
to: str | None = None,
|
||
):
|
||
# --- validation (contract) ---
|
||
if window not in _INC_WINDOWS:
|
||
return JSONResponse({"error": {"type": "bad_request",
|
||
"message": "window must be one of today|week|month|custom"}}, status_code=400)
|
||
f, t = _clean(from_), _clean(to)
|
||
if window == "custom" and not f and not t:
|
||
return JSONResponse({"error": {"type": "bad_request",
|
||
"message": "custom window requires from and/or to"}}, status_code=400)
|
||
def _parse(v):
|
||
if not v: return None
|
||
try: return datetime.fromisoformat(v)
|
||
except ValueError: return False
|
||
pf, pt = _parse(f), _parse(t)
|
||
if pf is False or pt is False:
|
||
return JSONResponse({"error": {"type": "bad_request",
|
||
"message": "from/to must be ISO-8601 with offset"}}, status_code=400)
|
||
if pf and pt and pf >= pt:
|
||
return JSONResponse({"error": {"type": "bad_request",
|
||
"message": "from must be < to"}}, status_code=400)
|
||
# --- one passthrough call ---
|
||
try:
|
||
with get_conn() as conn, conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT reporting.fn_inc_dashboard(%s, %s, %s, %s, %s)",
|
||
(_clean(cluster), _clean(status), window, f, t),
|
||
)
|
||
payload = cur.fetchone()[0] or {}
|
||
return JSONResponse(payload) # JSON body unchanged
|
||
except Exception:
|
||
log.exception("inc-dashboard failed")
|
||
return JSONResponse({"error": {"type": "unknown",
|
||
"message": "INC dashboard is unavailable. Try again in a few seconds."}})
|
||
```
|
||
- `datetime` is already imported (`from datetime import …`).
|
||
- Leave the legacy `/webhook/tickets` handler untouched (CRQ / fallback).
|
||
|
||
### Step A4 — Deploy to staging
|
||
|
||
```bash
|
||
cd ~/Downloads/projects/tracksolid_timescale_grafana_prod
|
||
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'
|
||
```
|
||
The script stages the file into the mount and **recreates** the `dashboard_api_staging`
|
||
container (CORS already allows `https://fleetops.fivetitude.com`).
|
||
|
||
### Step A5 — Verify the endpoint
|
||
|
||
```bash
|
||
B=https://fleetapi.fivetitude.com
|
||
curl -s "$B/webhook/inc-dashboard" | head -c 400; echo # 200, today
|
||
curl -s "$B/webhook/inc-dashboard?window=month" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["metrics"])'
|
||
curl -s "$B/webhook/inc-dashboard?status=ACCEPTED" | python3 -c 'import sys,json;d=json.load(sys.stdin);print("open",d["metrics"]["open_now"])'
|
||
curl -s -o /dev/null -w "%{http_code}\n" "$B/webhook/inc-dashboard?window=bogus" # 400
|
||
curl -s -o /dev/null -w "%{http_code}\n" "$B/webhook/inc-dashboard?window=custom" # 400
|
||
```
|
||
Cross-check `metrics.open_now` against `SELECT count(*) FROM tickets.inc WHERE is_actionable`.
|
||
|
||
---
|
||
|
||
## Phase B — SPA overhaul (`15_fleetops/src/index.html`, single file)
|
||
|
||
### Step B1 — Erase the existing INC/CRQ view
|
||
- Delete the full-bleed `#view-tickets` map section (markup, ~lines 374–390).
|
||
- Remove `loadTickets()` (calls `/webhook/tickets`), the **CRQ** circle layer, the
|
||
combined INC/CRQ summary handling, `TICKET_COLORS`, `ticketStats.crq`, and the old
|
||
`showTicketPopup()` (rebuilt for INC below).
|
||
- Keep the map/marker/popup CSS (~lines 182–252) and the warm-dark palette.
|
||
|
||
### Step B2 — Reuse (do NOT reinvent)
|
||
Vehicle overlay machinery stays: `loadLive()` (`/webhook/live-positions`, 15s poll),
|
||
`upsertVeh()`, `showVehPopup()`, `vehState()`, `ccColor()`, `pastel()`, `plateTail()`,
|
||
`BASEMAP`, `COST_CENTRE_COLORS`, `CC_PALETTE`, `escapeHtml`, `updateVehScale()`, the
|
||
layers-panel builder. Reuse the Logistics/Fuel filterbar + `.card`/`.span*` grid +
|
||
`num()`/`intg()` + custom-range show/hide (`:467`).
|
||
|
||
### Step B3 — New `#view-tickets` markup (dashboard cards + map)
|
||
- **Filterbar:** `Cluster` select · `Status` select · `Window` select
|
||
(Today / This week / This month / Custom) + custom start/end dates · `Apply` · `↻`.
|
||
- **`<main>` 12-col grid:**
|
||
- Metric cards: **Open now**, **Closed in window**, **Open SLA**
|
||
(breached/at-risk/ok/unknown), **Closed SLA** (compliant/breached), **Avg MTTR**
|
||
(min→h), **Closure rate** (`per_day_avg` + Chart.js sparkline from `closure_rate.series`).
|
||
- **Map card** (`.span12`, tall) with layer toggles + SLA legend.
|
||
- **By status** + **By cluster** tables (`.span6` each).
|
||
- **Freshness** line (exported_at / records_ingested / ingested_at).
|
||
|
||
### Step B4 — New JS (INC data + map)
|
||
- `incQs()` → query string; `loadInc()` → `fetch(${API_BASE}/webhook/inc-dashboard?…)`.
|
||
- Populate Cluster/Status dropdowns from the first unfiltered response's
|
||
`metrics.by_cluster` / `metrics.by_status` keys (no dedicated filters endpoint).
|
||
- **Layers** (GeoJSON sources):
|
||
- **Open INC** — circle colored by `sla_state`: breached=`--danger`, at_risk=`--warn`,
|
||
ok=`--live`, unknown=`--parked`; data = `open.features`.
|
||
- **Closed INC** — dimmed/hollow grey; data = `closed.features`; toggle (default off).
|
||
- **Vehicles** — existing DOM markers via `loadLive()`.
|
||
- **Popups:** open → ticket_id, normalized_status, cluster·region, assigned_team/owner,
|
||
sla_state + hours_open, geo_source ("approx — cluster" when `geo_source==='cluster'`).
|
||
closed → add closed_at, mttr (min→h), sla_status.
|
||
- Repurpose `renderTicketKpis()` → `renderIncKpis()` (Open now / Breached / Closed in
|
||
window / Avg MTTR). `switchTab('tickets')` → `initIncMap()` + lazy `loadInc()`.
|
||
- `Apply`/`↻`/window-change → `loadInc()`; keep the 15s vehicle poll.
|
||
- **Caveat:** drive the **map** from `*.features`, drive **cards/tables** from
|
||
`metrics` (`open.features.length` may be `< metrics.open_now` for un-geocoded rows).
|
||
|
||
### Step B5 — Verify the SPA locally
|
||
```bash
|
||
cd ~/Downloads/projects/15_fleetops/src
|
||
python3 -m http.server 8080 # API_BASE defaults to https://fleetapi.fivetitude.com
|
||
# open http://localhost:8080 → Tickets tab
|
||
```
|
||
Confirm: cards + header KPIs populate; by-status/by-cluster match `metrics`; open INC
|
||
SLA-colored + vehicles render; Closed INC toggle overlays the windowed set; changing
|
||
Cluster/Status/Window + Apply refetches; popups show documented fields; the network tab
|
||
shows only `/webhook/inc-dashboard` + `/webhook/live-positions` (no `/webhook/tickets`).
|
||
|
||
---
|
||
|
||
## Rollback
|
||
- **API:** the change is additive (new route). To revert, remove the handler and
|
||
redeploy; the legacy `/webhook/tickets` is unchanged. DB migrations are forward-only
|
||
but idempotent and unused by the old path.
|
||
- **SPA:** single file under git — revert `src/index.html`.
|
||
|
||
## Out of scope (future)
|
||
- **CRQ** rebuild (same pattern once a CRQ feed/function exists).
|
||
- Open-backlog-over-time / observed transitions (needs `16_fleettickets` history capture).
|
||
- Nearest-vehicle dispatch off `geog`.
|