Actionable runbook: replace the combined INC/CRQ Tickets map with the documented INC dashboard. Companion to tickets-inc-overhaul-plan.md. CRQ deferred.
Step A1 — Check whether reporting.fn_inc_dashboard is deployed
+
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
+
+
Signature printed → skip A2, go to A3.
+
None → run A2.
+
+
+
Step A2 — Apply migrations (idempotent, ledgered)
+
python run_migrations.py
+
Applies unapplied migrations/*.sql in order; 01–08 are skipped.
+ Expected new: 09_inc_dashboard_fn.sql (and 10_inc_history_capture.sql if absent).
+ All migrations are CREATE OR REPLACE / IF NOT EXISTS. Sanity check:
Caveat: drive the map from *.features, drive cards/tables from metrics (open.features.length may be < metrics.open_now).
+
+
+
Step B5 — Verify the SPA locally
+
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; Cluster/Status/Window + Apply refetches;
+ popups show documented fields; network tab shows only /webhook/inc-dashboard + /webhook/live-positions.
+
+
+
Rollback
+
+
API: additive route — remove the handler and redeploy to revert; /webhook/tickets 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.
+
+
+
Implementation runbook for the FleetOps Tickets → INC overhaul.
+
+
+
diff --git a/docs/tickets-inc-implementation-guide.md b/docs/tickets-inc-implementation-guide.md
new file mode 100644
index 0000000..3cb27f6
--- /dev/null
+++ b/docs/tickets-inc-implementation-guide.md
@@ -0,0 +1,240 @@
+# 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` · `↻`.
+- **`` 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`.
diff --git a/docs/tickets-inc-overhaul-plan.html b/docs/tickets-inc-overhaul-plan.html
new file mode 100644
index 0000000..2a19f35
--- /dev/null
+++ b/docs/tickets-inc-overhaul-plan.html
@@ -0,0 +1,302 @@
+
+
+
+
+
+FleetOps — Tickets → INC operations dashboard (overhaul plan)
+
+
+
+
+
+
+
FleetOps — Tickets → INC operations dashboard
+
Implementation plan · erase existing INC/CRQ view, rebuild INC first · endpoint-first, dashboard-cards layout
+
+
+
+
+
+
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.
Phase A API endpoint — repo tracksolid_timescale_grafana_prod
+
+ File: ~/Downloads/projects/tracksolid_timescale_grafana_prod/dashboard_api_rev.py.
+ Deployed by scp + ssh to the remote host; the 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 ! <cmd>.
+
+
+
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 writeDATABASE_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.
Mirror the existing tickets() handler (dashboard_api_rev.py:275-304): one
+ passthrough SQL call, JSON body returned unchanged. Reuse get_conn, _clean.
+
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 400s 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 ↻.
+
<main> 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).
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.
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)
+
+
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).
+
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:
+
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.
+
+
+
Generated as the implementation plan for the FleetOps Tickets → INC overhaul.
+
+
+
+
diff --git a/docs/tickets-inc-overhaul-plan.md b/docs/tickets-inc-overhaul-plan.md
new file mode 100644
index 0000000..628b065
--- /dev/null
+++ b/docs/tickets-inc-overhaul-plan.md
@@ -0,0 +1,184 @@
+# 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`.
diff --git a/src/index.html b/src/index.html
index 84826ae..23e89d2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -179,20 +179,11 @@
.banner.error { background: var(--error-bg); border-color: rgba(239,91,91,.45); color: var(--danger); }
.loading { opacity: .45; pointer-events: none; }
- /* ── Tickets map (FleetNow-style) ────────────────────────────────────── */
- .map-wrap { position: relative; height: calc(100vh - 50px); }
- #tk-map { position: absolute; inset: 0; }
+ /* ── Tickets / INC dashboard map ─────────────────────────────────────── */
+ .map-wrap { position: relative; height: 540px; }
+ #tk-map { position: absolute; inset: 0; border-radius: 8px; overflow: hidden; }
.map-ctl { position: absolute; z-index: 5; font: 600 11px system-ui; color: #fff; user-select: none; }
#tk-layers { right: 10px; top: 10px; }
- #tk-statusbar {
- left: 10px; top: 10px; background: rgba(15,18,23,.92); border: 1px solid var(--border);
- border-radius: 6px; padding: 5px 9px; box-shadow: 0 2px 8px rgba(0,0,0,.45);
- }
- #tk-statusbar label { display: flex; align-items: center; gap: 7px; color: var(--muted); }
- #tk-statusbar select {
- background: var(--bg); color: var(--text); border: 1px solid var(--border);
- border-radius: 5px; padding: 4px 7px; font: 600 11px system-ui;
- }
.layers-toggle {
cursor: pointer; border: 1px solid var(--border); background: rgba(15,18,23,.92);
color: #fff; font: 600 11px system-ui; padding: 4px 10px; border-radius: 6px;
@@ -209,6 +200,23 @@
.layers-row input { accent-color: var(--accent); margin: 0; }
.layers-row .legend-dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; border: 1px solid rgba(255,255,255,.5); }
.layers-n { margin-left: auto; color: var(--muted); font-weight: 700; }
+ .legend-sep {
+ border-top: 1px solid var(--border); margin: 6px 0 2px; padding-top: 6px;
+ font-size: 9px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted);
+ }
+
+ /* INC metric strip */
+ .metric-row { display: flex; flex-wrap: wrap; gap: 26px; }
+ .metric { display: flex; flex-direction: column; min-width: 96px; }
+ .metric b { font-size: 22px; line-height: 1.1; font-variant-numeric: tabular-nums; }
+ .metric b.accent { color: var(--accent); }
+ .metric .lbl { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; margin-top: 3px; }
+ .metric .sub { margin-top: 6px; font-size: 11px; display: flex; gap: 10px; flex-wrap: wrap; font-variant-numeric: tabular-nums; }
+ .metric .sub i { font-style: normal; }
+ .sla-breached { color: var(--danger); }
+ .sla-at_risk { color: var(--warn); }
+ .sla-ok { color: var(--live); }
+ .sla-unknown { color: var(--muted); }
/* Live vehicle DOM marker (ported from FleetNow) */
.veh-marker { cursor: pointer; will-change: transform; }
@@ -372,21 +380,62 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
INC overview
+
Loading…
+
+
+
+
Live INC map open (SLA-coloured) · closed overlay · vehicles
+
+
+
+
+
+
+
+
+
Closures — daily
+
+
+
+
+
By status
+
Loading…
+
+
+
+
By cluster
+
Loading…
+
+
@@ -791,25 +840,16 @@ $('fuf-refresh').addEventListener('click', loadFuel);
// The header KPI strip is shared, so we cache the last logistics totals and
// re-render them when switching back from Tickets.
let lastTotals = null, lastFuelL = 0;
-const ticketStats = {}; // { inc, crq, vehicles, moving } — filled by the tickets map
-
-function renderTicketKpis() {
- const k = [
- ['', ticketStats.inc ?? '—', 'INC open'],
- ['', ticketStats.crq ?? '—', 'CRQ open'],
- ['accent', ticketStats.vehicles ?? '—', 'Vehicles'],
- ['live', ticketStats.moving ?? '—', 'Moving'],
- ];
- $('kpis').innerHTML = k.map(([c, v, l]) =>
- `
${v}${l}
`).join('');
-}
+// INC ticket KPIs + metrics are rendered by the INC dashboard section below
+// (renderIncKpis / renderIncMetrics), driven by GET /webhook/inc-dashboard.
function switchTab(name) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === `view-${name}`));
if (name === 'tickets') {
- renderTicketKpis();
- initTicketsMap(); // lazy — builds once, then just resizes
+ if (incData) renderIncKpis(incData.metrics);
+ loadInc(); // (re)load INC data first — independent of the basemap
+ initIncMap(); // then build the map (lazy) / just resize if built
} else if (name === 'fuel') {
if (lastFuelTotals) renderFuelKpis(lastFuelTotals);
if (!fuelLoaded) loadFuel(); // lazy — first open
@@ -822,18 +862,22 @@ document.querySelectorAll('.tab').forEach(b =>
b.addEventListener('click', () => switchTab(b.dataset.tab)));
// ============================================================================
-// TICKETS MAP — FleetNow-style live map + INC/CRQ ticket layers
+// TICKETS — INC operations dashboard (open layer + windowed closed overlay)
// ============================================================================
-// Reads the same read-API as FleetNow/Logistics (API_BASE):
-// GET /webhook/live-positions → vehicle DOM markers (ported from FleetNow)
-// GET /webhook/tickets → INC (red) + CRQ (blue) circle layers
-// Tickets are geocoded server-side (cluster gazetteer, migration 21); only
-// rows with a geom come back. Map is lazy-initialised on first Tickets open.
+// Reads the new dashboard_api endpoint (API_BASE):
+// GET /webhook/inc-dashboard?cluster=&status=&window=&from=&to=
+// → { window, open: GeoJSON, closed: GeoJSON, metrics, freshness }
+// GET /webhook/live-positions → live FleetNow vehicle DOM markers (overlay)
+// Only geocoded INC rows become map features; metrics count the full set. The
+// map is lazy-initialised on first Tickets open; INC data refetched on Apply.
const BASEMAP = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
const OFFLINE_THRESHOLD_MS = 24 * 3600 * 1000;
const STALE_GPS_MS = 10 * 60 * 1000;
const LIVE_POLL_MS = 15000;
-const TICKET_COLORS = { inc: '#ef5b5b', crq: '#3b82f6' };
+const EMPTY_FC = { type: 'FeatureCollection', features: [] };
+// SLA state → colour (open layer + legend); mirrors the warm-dark palette.
+const SLA_COLORS = { breached: '#ef5b5b', at_risk: '#f0a93b', ok: '#2dd4a7', unknown: '#6b7280' };
+const SLA_LABELS = { breached: 'Breached', at_risk: 'At risk', ok: 'OK', unknown: 'Unknown' };
const COST_CENTRE_COLORS = {
'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e',
@@ -867,47 +911,92 @@ function vehState(p) {
return 'active';
}
-let tkMap = null, tkPopup = null, tkLivePoll = null;
+let tkMap = null, tkPopup = null, tkLivePoll = null, tkClosureChart = null;
const tkMarkers = new Map(); // imei → maplibregl.Marker
-const tkLayerState = { vehicles: true, inc: true, crq: true };
-let tkStatusOpenOnly = true;
+const tkLayerState = { open: true, closed: false, vehicles: true };
+let incData = null, incDropdownsInit = false, vehCount = 0;
-function initTicketsMap() {
+// ── INC helpers ───────────────────────────────────────────────────────────
+const mttrFmt = (min) => (min == null || isNaN(min)) ? '—' : num(min / 60, 1) + ' h';
+function eatShort(iso) {
+ if (!iso) return '—';
+ const d = new Date(iso); if (isNaN(d)) return '—';
+ return d.toLocaleString('en-GB', { timeZone: 'Africa/Nairobi', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
+}
+function addDay(ymd) { const [y, m, d] = ymd.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10); }
+
+function incQs() {
+ const p = new URLSearchParams();
+ if ($('tk-cluster').value) p.set('cluster', $('tk-cluster').value);
+ if ($('tk-status').value) p.set('status', $('tk-status').value);
+ const w = $('tk-window').value;
+ if (w === 'custom') {
+ if ($('tk-start').value) p.set('from', $('tk-start').value + 'T00:00:00+03:00');
+ if ($('tk-end').value) p.set('to', addDay($('tk-end').value) + 'T00:00:00+03:00'); // inclusive end
+ } else {
+ p.set('window', w);
+ }
+ return p.toString();
+}
+
+function initIncMap() {
if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing
+
+ // Filter / control listeners (attached once, with the map).
+ $('tk-window').addEventListener('change', () => {
+ const custom = $('tk-window').value === 'custom';
+ $('tk-ff-start').classList.toggle('show', custom);
+ $('tk-ff-end').classList.toggle('show', custom);
+ });
+ $('tk-apply').addEventListener('click', loadInc);
+ $('tk-refresh').addEventListener('click', loadInc);
+ $('tk-layers-toggle').addEventListener('click', () => $('tk-layers').classList.toggle('collapsed'));
+
tkMap = new maplibregl.Map({
- container: 'tk-map', style: BASEMAP, center: [37.5, -3.0], zoom: 5.2,
+ container: 'tk-map', style: BASEMAP, center: [37.5, -1.1], zoom: 6,
attributionControl: { compact: true },
});
tkMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
tkPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 14 });
tkMap.on('load', () => {
- tkMap.addSource('tk-tickets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
- for (const t of ['inc', 'crq']) {
- tkMap.addLayer({
- id: 'tk-' + t, type: 'circle', source: 'tk-tickets',
- filter: ['==', ['get', 'service_type'], t],
- paint: {
- 'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 4, 11, 6, 16, 9],
- 'circle-color': TICKET_COLORS[t],
- 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5, 'circle-opacity': 0.9,
- },
- });
- tkMap.on('mouseenter', 'tk-' + t, () => { tkMap.getCanvas().style.cursor = 'pointer'; });
- tkMap.on('mouseleave', 'tk-' + t, () => { tkMap.getCanvas().style.cursor = ''; tkPopup.remove(); });
- tkMap.on('mousemove', 'tk-' + t, (e) => showTicketPopup(e.features[0]));
+ // Closed overlay (windowed) under the live open layer.
+ tkMap.addSource('inc-closed', { type: 'geojson', data: EMPTY_FC });
+ tkMap.addLayer({
+ id: 'inc-closed', type: 'circle', source: 'inc-closed',
+ layout: { visibility: tkLayerState.closed ? 'visible' : 'none' },
+ paint: {
+ 'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 3, 11, 5, 16, 7],
+ 'circle-color': '#6b7280', 'circle-opacity': 0.45,
+ 'circle-stroke-color': '#9ca3af', 'circle-stroke-width': 1,
+ },
+ });
+ // Open layer (live) — coloured by derived SLA state.
+ tkMap.addSource('inc-open', { type: 'geojson', data: EMPTY_FC });
+ tkMap.addLayer({
+ id: 'inc-open', type: 'circle', source: 'inc-open',
+ layout: { visibility: tkLayerState.open ? 'visible' : 'none' },
+ paint: {
+ 'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 4, 11, 6, 16, 9],
+ 'circle-color': ['match', ['get', 'sla_state'],
+ 'breached', SLA_COLORS.breached, 'at_risk', SLA_COLORS.at_risk, 'ok', SLA_COLORS.ok,
+ SLA_COLORS.unknown],
+ 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5, 'circle-opacity': 0.92,
+ },
+ });
+ for (const id of ['inc-open', 'inc-closed']) {
+ tkMap.on('mouseenter', id, () => { tkMap.getCanvas().style.cursor = 'pointer'; });
+ tkMap.on('mouseleave', id, () => { tkMap.getCanvas().style.cursor = ''; tkPopup.remove(); });
+ tkMap.on('mousemove', id, (e) => showIncPopup(e.features[0], id === 'inc-closed'));
}
tkMap.on('zoom', updateVehScale); updateVehScale();
- loadTickets();
+ if (incData) { // INC data may have arrived before the basemap finished loading
+ tkMap.getSource('inc-open').setData(incData.open || EMPTY_FC);
+ tkMap.getSource('inc-closed').setData(incData.closed || EMPTY_FC);
+ }
loadLive();
tkLivePoll = setInterval(loadLive, LIVE_POLL_MS);
});
-
- $('tk-status').addEventListener('change', () => {
- tkStatusOpenOnly = $('tk-status').value === 'open';
- loadTickets();
- });
- $('tk-layers-toggle').addEventListener('click', () => $('tk-layers').classList.toggle('collapsed'));
}
function updateVehScale() {
@@ -916,17 +1005,100 @@ function updateVehScale() {
document.getElementById('tk-map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
}
-async function loadTickets() {
+// ── render: header KPIs + metric strip + tables + closure chart ─────────────
+function renderIncKpis(m) {
+ m = m || {}; const so = (m.sla && m.sla.open) || {};
+ const k = [
+ ['accent', intg(m.open_now), 'INC open'],
+ ['warn', intg(so.breached), 'Breached'],
+ ['', intg(m.closed_in_window), 'Closed (win)'],
+ ['live', mttrFmt(m.avg_mttr_min), 'Avg MTTR'],
+ ];
+ $('kpis').innerHTML = k.map(([c, v, l]) =>
+ `