From e32ec92cbf5a0744e73d31fb118fc8007859a258 Mon Sep 17 00:00:00 2001 From: david kiania Date: Tue, 16 Jun 2026 11:42:23 +0300 Subject: [PATCH] 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 --- docs/tickets-inc-implementation-guide.html | 261 ++++++++++++++ docs/tickets-inc-implementation-guide.md | 240 +++++++++++++ docs/tickets-inc-overhaul-plan.html | 302 ++++++++++++++++ docs/tickets-inc-overhaul-plan.md | 184 ++++++++++ src/index.html | 385 +++++++++++++++------ 5 files changed, 1270 insertions(+), 102 deletions(-) create mode 100644 docs/tickets-inc-implementation-guide.html create mode 100644 docs/tickets-inc-implementation-guide.md create mode 100644 docs/tickets-inc-overhaul-plan.html create mode 100644 docs/tickets-inc-overhaul-plan.md diff --git a/docs/tickets-inc-implementation-guide.html b/docs/tickets-inc-implementation-guide.html new file mode 100644 index 0000000..16b00d0 --- /dev/null +++ b/docs/tickets-inc-implementation-guide.html @@ -0,0 +1,261 @@ + + + + + +FleetOps — INC operations dashboard · implementation guide + + + +
+
+ +

FleetOps — INC operations dashboard · implementation guide

+
Actionable runbook: replace the combined INC/CRQ Tickets map with the documented INC dashboard. Companion to tickets-inc-overhaul-plan.md. CRQ deferred.
+
+
+ +
+ +

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 prod DB).
  • +
  • DB: tracksolid_db on twala.rahamafresh.com:5433 (direct psql/psycopg2 via the write DATABASE_URL).
  • +
+ +

1. Prerequisites & access

+ + + + + + + + +
NeedStatus / how
Write DATABASE_URL to tracksolid_dbProvided by user; export as DATABASE_URL (do not commit).
Python + psycopg2Use 16_fleettickets/.venv.
Deploy access to staging hostscp + ssh kianiadee@twala.rahamafresh.com (SSH config entry exists).
Source repos15_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 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:

+
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): from fastapi import FastAPI, Request, Query
  2. +
  3. Add the handler near tickets():
  4. +
+
_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. Leave the legacy /webhook/tickets handler untouched.
  • +
+ +

Step A4 — Deploy to staging

+
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 and recreates the dashboard_api_staging container + (CORS already allows https://fleetops.fivetitude.com).

+ +

Step A5 — Verify the endpoint

+
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)

+ +

Step B1 — Erase the existing INC/CRQ view

+
    +
  • Delete the full-bleed #view-tickets map section (markup, ~lines 374–390).
  • +
  • Remove loadTickets() (/webhook/tickets), the CRQ circle layer, combined summary handling, TICKET_COLORS, ticketStats.crq, old showTicketPopup().
  • +
  • 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 · Status · Window (Today / This week / This month / Custom) + custom 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 with layer toggles + SLA legend; By status + By cluster tables .span6 each; Freshness line.
  • +
+ +

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.
  • +
  • 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(); 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).
  • +
+ +

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.
  • +
+ +

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.mdfn_inc_dashboard signature + metrics.
  • +
+ +
+ +

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 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.

+
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).
    • +
    • 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. +
  3. 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.
    • +
    +
  4. +
+ +

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 @@
-
-
-
- -
-
@@ -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]) => + `
${v}${l}
`).join(''); +} + +function renderIncMetrics(m, freshness) { + m = m || {}; + const so = (m.sla && m.sla.open) || {}, sc = (m.sla && m.sla.closed) || {}, cr = m.closure_rate || {}; + const tiles = [ + `
${intg(m.open_now)}Open now
`, + `
${intg(m.closed_in_window)}Closed in window
`, + `
${intg(so.breached)}Open breached +
${intg(so.at_risk)} at-risk${intg(so.ok)} ok${intg(so.unknown)} unkn
`, + `
${intg(sc.compliant)} / ${intg(sc.breached)}Closed compliant / breached
`, + `
${mttrFmt(m.avg_mttr_min)}Avg MTTR
`, + `
${num(cr.per_day_avg, 1)}Closures / day
`, + ]; + $('tk-metrics').innerHTML = tiles.join(''); + const fr = freshness && freshness.inc; + $('tk-fresh').textContent = fr ? `updated ${eatShort(fr.ingested_at)} · ${intg(fr.records_ingested)} records` : ''; +} + +function incTable(obj) { + const rows = Object.entries(obj || {}).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]); + if (!rows.length) return { n: 0, html: '
No data.
' }; + const body = rows.map(([k, v]) => `${escapeHtml(k)}${intg(v)}`).join(''); + return { n: rows.length, html: `${body}
NameCount
` }; +} +function renderIncTables(m) { + m = m || {}; + const s = incTable(m.by_status), c = incTable(m.by_cluster); + $('tk-status-wrap').innerHTML = s.html; $('tk-status-count').textContent = s.n ? `(${s.n})` : ''; + $('tk-cluster-wrap').innerHTML = c.html; $('tk-cluster-count').textContent = c.n ? `(${c.n})` : ''; +} + +function renderClosureChart(cr) { + const series = (cr && cr.series) || []; + const labels = series.map(d => d.day), data = series.map(d => Number(d.count || 0)); + const css = getComputedStyle(document.documentElement); + const accent = css.getPropertyValue('--accent').trim(); + const muted = css.getPropertyValue('--muted').trim(); + const border = css.getPropertyValue('--border').trim(); + const cfg = { + data: { labels, datasets: [{ type: 'bar', label: 'Closures', data, backgroundColor: accent + 'cc', borderRadius: 3 }] }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { grid: { color: border }, border: { color: border }, ticks: { color: muted, maxRotation: 0, autoSkip: true } }, + y: { grid: { color: border }, border: { color: border }, ticks: { color: muted, precision: 0 }, beginAtZero: true }, + }, + }, + }; + if (tkClosureChart) { tkClosureChart.data = cfg.data; tkClosureChart.options = cfg.options; tkClosureChart.update(); } + else tkClosureChart = new Chart($('tk-closureChart'), cfg); +} + +// Populate the Cluster / Status filters from the first unfiltered response. +function initIncDropdowns(m) { + const fill = (id, obj) => { + const el = $(id); + Object.keys(obj || {}).filter(k => k !== '').sort().forEach(k => el.add(new Option(k, k))); + }; + fill('tk-cluster', m.by_cluster); + fill('tk-status', m.by_status); +} + +async function loadInc() { + $('tk-main').classList.add('loading'); try { - const r = await fetch(`${API_BASE}/webhook/tickets?open_only=${tkStatusOpenOnly}`, - { headers: { 'Accept': 'application/json' } }); - const j = await r.json(); - const gj = j.geojson || { type: 'FeatureCollection', features: [] }; - if (tkMap.getSource('tk-tickets')) tkMap.getSource('tk-tickets').setData(gj); - const s = j.summary || {}; - ticketStats.inc = s.inc ?? 0; ticketStats.crq = s.crq ?? 0; - renderTicketKpis(); buildTkLayers(); - } catch (e) { console.warn('tickets', e); } + const j = await api(`/webhook/inc-dashboard?${incQs()}`); + incData = j; + if (!incDropdownsInit && j.metrics) { initIncDropdowns(j.metrics); incDropdownsInit = true; } + renderIncKpis(j.metrics); + renderIncMetrics(j.metrics, j.freshness); + renderIncTables(j.metrics); + renderClosureChart(j.metrics && j.metrics.closure_rate); + if (tkMap && tkMap.getSource('inc-open')) tkMap.getSource('inc-open').setData(j.open || EMPTY_FC); + if (tkMap && tkMap.getSource('inc-closed')) tkMap.getSource('inc-closed').setData(j.closed || EMPTY_FC); + buildIncLayers(); + } catch (e) { + console.error(e); + $('tk-metrics').innerHTML = ``; + } finally { + $('tk-main').classList.remove('loading'); + } } async function loadLive() { @@ -941,9 +1113,8 @@ async function loadLive() { } for (const [imei, m] of tkMarkers) if (!seen.has(imei)) { m.remove(); tkMarkers.delete(imei); } const s = j.summary || {}; - ticketStats.vehicles = s.vehicle_count ?? feats.length; - ticketStats.moving = s.moving ?? 0; - renderTicketKpis(); buildTkLayers(); + vehCount = s.vehicle_count ?? feats.length; + buildIncLayers(); } catch (e) { console.warn('live', e); } } @@ -990,35 +1161,45 @@ function showVehPopup(p, coords) {
last fix ${escapeHtml(p.gps_time || '—')}
`).addTo(tkMap); } -function showTicketPopup(f) { +function showIncPopup(f, closed) { const p = f.properties || {}; - const t = (p.service_type || '').toUpperCase(); + const lines = [`
${escapeHtml(p.normalized_status || '—')}
`]; + if (p.cluster) lines.push(`
${escapeHtml(p.cluster)}${p.region ? ' · ' + escapeHtml(p.region) : ''}
`); + const who = p.assigned_team || p.owner; + if (who) lines.push(`
${escapeHtml(who)}
`); + if (closed) { + lines.push(`
closed ${escapeHtml(eatShort(p.closed_at))} · MTTR ${mttrFmt(p.mttr)}
`); + if (p.sla_status) lines.push(`
${escapeHtml(p.sla_status)}
`); + } else { + const st = p.sla_state || 'unknown'; + lines.push(`
${SLA_LABELS[st] || st}${p.hours_open != null ? ' · ' + num(p.hours_open, 0) + 'h open' : ''}
`); + } + if (p.geo_source === 'cluster') lines.push('
approx — cluster location
'); tkPopup.setLngLat(f.geometry.coordinates).setHTML(`
- ${escapeHtml(p.ticket_id || '—')} ${t} -
${escapeHtml(p.status || '—')}
- ${p.cluster ? `
${escapeHtml(p.cluster)}${p.region ? ' · ' + escapeHtml(p.region) : ''}
` : ''} - ${p.owner ? `
${escapeHtml(p.owner)}
` : ''} - ${p.department ? `
${escapeHtml(p.department)}${p.sla_status ? ' · ' + escapeHtml(p.sla_status) : ''}
` : ''} - ${p.geo_source === 'cluster' ? '
approx — cluster location
' : ''} -
`).addTo(tkMap); + ${escapeHtml(p.ticket_id || '—')} ${closed ? 'CLOSED' : 'OPEN'} + ${lines.join('')}`).addTo(tkMap); } -function buildTkLayers() { +function buildIncLayers() { + const m = (incData && incData.metrics) || {}; const rows = [ - { id: 'vehicles', label: 'Vehicles', color: '#E8954A', n: tkMarkers.size }, - { id: 'inc', label: 'INC incidents', color: TICKET_COLORS.inc, n: ticketStats.inc ?? 0 }, - { id: 'crq', label: 'CRQ installs', color: TICKET_COLORS.crq, n: ticketStats.crq ?? 0 }, + { id: 'open', label: 'Open INC', color: SLA_COLORS.breached, n: m.open_now ?? 0 }, + { id: 'closed', label: 'Closed INC', color: '#9ca3af', n: m.closed_in_window ?? 0 }, + { id: 'vehicles', label: 'Vehicles', color: '#E8954A', n: vehCount }, ]; - $('tk-layers-body').innerHTML = rows.map((r) => + let html = rows.map((r) => ``).join(''); + ${r.label}${intg(r.n)}`).join(''); + html += '
Open SLA
' + ['breached', 'at_risk', 'ok', 'unknown'].map((s) => + `
${SLA_LABELS[s]}
`).join(''); + $('tk-layers-body').innerHTML = html; $('tk-layers-body').querySelectorAll('input[type=checkbox]').forEach((cb) => cb.addEventListener('change', () => { const id = cb.getAttribute('data-lyr'); tkLayerState[id] = cb.checked; if (id === 'vehicles') { for (const [, m] of tkMarkers) m.getElement().style.display = cb.checked ? '' : 'none'; - } else if (tkMap.getLayer('tk-' + id)) { - tkMap.setLayoutProperty('tk-' + id, 'visibility', cb.checked ? 'visible' : 'none'); + } else if (tkMap && tkMap.getLayer('inc-' + id)) { + tkMap.setLayoutProperty('inc-' + id, 'visibility', cb.checked ? 'visible' : 'none'); } })); }