241 lines
11 KiB
Markdown
241 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`.
|