fleetops/docs/tickets-inc-implementation-guide.md
david kiania e32ec92cbf feat(tickets): replace INC/CRQ map with INC operations dashboard
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>
2026-06-16 11:42:23 +03:00

240 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (0108) 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 374390).
- 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 182252) 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`.