# Overhaul FleetOps Tickets → INC operations dashboard ## Context The FleetOps SPA's **Tickets** tab is currently a full-bleed MapLibre map showing combined **INC (red) + CRQ (blue)** ticket circles plus live FleetNow vehicles, fed by the legacy `GET /webhook/tickets` (→ `reporting.fn_tickets_for_map`). Meanwhile, the `16_fleettickets` repo has designed and documented a richer **INC operations dashboard** (Phase 2): an open-ticket layer + windowed closed overlay + derived SLA states + ticket metric cards, served by a new `reporting.fn_inc_dashboard(...)` function and exposed at `GET /webhook/inc-dashboard`. We are overhauling the SPA to that documented design. Per the user: **erase the existing INC + CRQ ticket view and rebuild INC first** (CRQ deferred). INC is fully documented; CRQ reuses the same machinery later. **Key blocker found:** `GET /webhook/inc-dashboard` currently **404s** — the DB function lives in `16_fleettickets/migrations/09_inc_dashboard_fn.sql` but the HTTP wrapper is not in the `dashboard_api` service. The legacy `/webhook/tickets` returns 200 with live INC+CRQ data (INC ingest is live: 21,301 records, freshness current). **Decisions (confirmed with user):** - **Endpoint first, then SPA** — build/verify the API endpoint (+ DB function) and confirm it returns real data, *then* overhaul the SPA against the live endpoint. - **Layout:** dashboard cards + map (matches the existing Logistics/Fuel tabs) — top filterbar (cluster / status / window), a metric-cards row, a large map card, and by-status / by-cluster tables below. ## Reference docs (source of truth) - `16_fleettickets/docs/dashboard-api-contract.md` — endpoint params, response shape, field semantics (mttr=minutes, sla_state derived, coords `[lng,lat]`, map-vs-metrics gap). - `16_fleettickets/docs/phase-2-dashboard.md` — `fn_inc_dashboard` signature + metrics. --- ## Phase A — API endpoint (separate repo: `tracksolid_timescale_grafana_prod`) > File: `~/Downloads/projects/tracksolid_timescale_grafana_prod/dashboard_api_rev.py`. > Deployed by scp + ssh to the remote host; **staging** instance > (`fleetapi.fivetitude.com`) runs read-only as `dashboard_ro`. These steps touch a > live server and may need the user to run the scp/ssh deploy via `! `. ### A1. Verify / apply the DB function - Confirm `reporting.fn_inc_dashboard` exists in the live DB. If absent, apply via `16_fleettickets/run_migrations.py` (needs the **write** `DATABASE_URL`; applies `09_inc_dashboard_fn.sql`, and `08`/`10` if not already in `tickets.schema_migrations`). Migrations are idempotent + ledgered, so re-running is safe. - Sanity check in psql: `SELECT reporting.fn_inc_dashboard();` → valid JSON (open/closed FeatureCollections, metrics, `window.preset='today'`, freshness). ### A2. Add the `/webhook/inc-dashboard` handler Mirror the existing `tickets()` handler (`dashboard_api_rev.py:275-304`): one passthrough SQL call, JSON body returned unchanged. Reuse `get_conn`, `_clean`. ```python from fastapi import FastAPI, Request, Query # add Query to existing import (line 46) @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"), # 'from' is reserved to: str | None = None, ): # Validation per the contract: # - window not in {today,week,month,custom} -> 400 # - window == 'custom' with neither from nor to -> 400 # - from/to unparseable, or from >= to -> 400 # If either from/to is present, the SQL treats it as custom (window overridden). 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, _clean(from_), _clean(to)), ) payload = cur.fetchone()[0] or {} return JSONResponse(payload) # passthrough, unchanged except Exception: log.exception("inc-dashboard failed") return JSONResponse({"error": {"type": "unknown", "message": "INC dashboard is unavailable. Try again in a few seconds."}}) ``` - Pass `from`/`to` as ISO-8601 strings; PostgreSQL casts text → `timestamptz` on the function call. Validate parseability API-side (e.g. `datetime.fromisoformat`) to return clean `400`s rather than a 500 from the DB. - Leave the legacy `/webhook/tickets` handler in place (CRQ / fallback may use it). ### A3. Deploy + verify - Deploy to staging: scp `dashboard_api_rev.py` → host, scp the staging deploy script, `ssh … bash ~/deploy_dashboard_api_staging.sh` (recreates the container). - Verify against `https://fleetapi.fivetitude.com`: - `GET /webhook/inc-dashboard` → 200, documented shape, `open`/`closed` FCs. - `?window=month`, `?cluster=MUIGAI%20INN`, `?status=ACCEPTED`, `?from=…%2B03:00&to=…%2B03:00` → counts sane; `open` not time-filtered. - `?window=bogus` → 400; `?window=custom` (no from/to) → 400. --- ## Phase B — SPA overhaul (`15_fleetops/src/index.html`, single file) ### B1. Erase the existing INC/CRQ view Remove from `src/index.html`: - **Markup:** the full-bleed map section `#view-tickets` (lines ~374-390). - **JS — drop:** `loadTickets()` (calls `/webhook/tickets`), the **CRQ** circle layer, combined INC/CRQ summary handling, `showTicketPopup()` (rebuild for INC), `TICKET_COLORS`, `ticketStats.crq`. - **CSS:** keep the map/marker/popup blocks (lines ~182-252) — reused; rename `#tk-*` selectors only if the new markup changes ids. ### B2. Keep + reuse (do NOT reinvent) The vehicle overlay machinery stays — the contract says the SPA overlays FleetNow: - `loadLive()` (`/webhook/live-positions`, 15s poll), `upsertVeh()`, `showVehPopup()`, `vehState()`, `ccColor()`, `pastel()`, `plateTail()`, `BASEMAP`, `COST_CENTRE_COLORS`, `CC_PALETTE`, `escapeHtml`, `updateVehScale()`, `initTicketsMap()` (rename → `initIncMap()`), the layers-panel builder, the MapLibre popup CSS, and the warm-dark palette. - Filterbar markup/behaviour pattern from the Logistics/Fuel tabs (`.filterbar`, custom-range show/hide at `index.html:467-471`, `.card`/`.span*` grid, table renderers, `num()`/`intg()`). ### B3. New markup — `#view-tickets` (dashboard cards + map) - **Filterbar:** `Cluster` select, `Status` select, `Window` select (Today / This week / This month / Custom) + custom start/end date inputs (reuse the `.ff.custom` show/hide), `Apply`, refresh `↻`. - **`
` 12-col grid:** - Metric cards row: **Open now**, **Closed in window**, **Open SLA** (breached / at-risk / ok / unknown), **Closed SLA** (compliant / breached), **Avg MTTR** (minutes → show as h), **Closure rate** (`per_day_avg` + a small Chart.js sparkline from `closure_rate.series`). - **Map card** (`.span12`, tall): MapLibre map with layer toggles + SLA legend. - **By status** table + **By cluster** table (`.span6` each) from `metrics.by_status` / `metrics.by_cluster`. - **Freshness** line (exported_at / records_ingested / ingested_at) under the map. ### B4. New JS — INC data + map - **State:** `incQs()` builds query (`cluster`, `status`, `window`, and `from`/`to` when custom). `loadInc()` → `fetch(${API_BASE}/webhook/inc-dashboard?…)`. - **Dropdowns:** populate `Cluster` / `Status` from the first unfiltered response's `metrics.by_cluster` / `metrics.by_status` keys (no dedicated filters endpoint exists); keep stable thereafter. - **Map layers** on one or two GeoJSON sources: - **Open INC** — circle layer colored by `sla_state` (`breached`=`--danger`, `at_risk`=`--warn`, `ok`=`--live`, `unknown`=`--parked`); data = `open.features`. - **Closed INC** — distinct dimmed style (e.g. hollow grey), data = `closed.features`; toggleable (default off). - **Vehicles** — existing DOM markers via `loadLive()`. - Layer panel: Open INC / Closed INC / Vehicles toggles + SLA color legend. - **Popups:** open → `ticket_id`, `normalized_status`, `cluster · region`, `assigned_team`/`owner`, `sla_state` + `hours_open`, `geo_source` (note "approx — cluster" when `geo_source==='cluster'`). closed → add `closed_at`, `mttr` (min→h), `sla_status`. - **Header KPI strip:** repurpose `renderTicketKpis()` → `renderIncKpis()` showing INC metrics (Open now, Breached, Closed in window, Avg MTTR). Update `switchTab()` so the `tickets` case calls `initIncMap()` + `loadInc()` (lazy, like Fuel). - **Filters:** `Apply`/`↻`/window-change → `loadInc()`. Keep the 15s vehicle poll; `loadInc()` is on-demand (open layer changes at most hourly). - **Caveat to honor:** `open.features.length` may be `< metrics.open_now` (un-geocoded rows) — drive map from `features`, drive cards/tables from `metrics`. --- ## Verification (end-to-end) 1. **API (Phase A):** curl matrix above against `fleetapi.fivetitude.com` — shapes, filters, 400s. Compare `metrics.open_now` to `SELECT count(*) FROM tickets.inc WHERE is_actionable` (and `inc_open_sla` SLA distribution). 2. **SPA (Phase B):** serve `src/` locally (`python3 -m http.server` in `src/`, or the Caddy Docker image) with `API_BASE=https://fleetapi.fivetitude.com`. Open the **Tickets** tab and confirm: - Metric cards + header KPIs populate; by-status / by-cluster tables match `metrics`. - Map shows SLA-colored open INC + live vehicles; toggling Closed INC overlays the windowed closed set; SLA legend correct. - Changing Cluster / Status / Window + Apply refetches and updates cards, tables, and both layers; custom range shows date inputs and bounds the closed overlay. - Hover popups show the documented fields (open vs closed). - No console calls to `/webhook/tickets`; only `/webhook/inc-dashboard` + `/webhook/live-positions`. ## Out of scope (future) - **CRQ** rebuild (deferred; reuses the same pattern once a CRQ feed/function exists). - Open-backlog-over-time / observed transitions (needs `16_fleettickets` history capture — not built). Nearest-vehicle dispatch off `geog`.