Compare commits

..

25 commits

Author SHA1 Message Date
david kiania
9fb39aa992 feat(tickets): center the overview, remove its filter row
The INC/CRQ overview is now a centered "today" snapshot — header + metric tiles
centered. Removed the overview filter row (Cluster/Status/Window/Apply/refresh):
the Ticket explorer below carries the ad-hoc filtering, so it was redundant.
incQs() is fixed to window=today; dropped initIncDropdowns + the removed listeners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:15 +03:00
david kiania
e58195073d chore: verify staging auto-deploy webhook 2026-06-26 15:49:05 +03:00
david kiania
6bddccb91a feat(tickets): per-dataset "Last updated" freshness in the explorer
Add a "Last updated <ingested_at> EAT" readout under the ticket-explorer Time filter
(right-aligned, its own line) so data freshness is visible while searching, for both
INC and CRQ. Also fix renderIncMetrics to read freshness[DS] instead of the hardcoded
freshness.inc, so the overview "updated" stamp reflects the active dataset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:33:32 +03:00
david kiania
82a6d11d95 feat(tickets): INC | CRQ dataset sub-tabs
Add a sub-tab bar under the Tickets tab (INC | CRQ). A DS dataset variable repoints
the dashboard/search/filter-options calls to /webhook/${DS}-* and updates the overview
/ map / legend labels; switching resets the per-dataset dropdowns + explorer and
reloads. Map, SLA legend, popups and the vehicle overlay are dataset-agnostic, so CRQ
renders "just like INC" off reporting.fn_crq_* (fleettickets migration 16).

NOT pushed yet — pushing staging auto-deploys; holding until the CRQ API + DB land.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:20:23 +03:00
david kiania
b2c4cbe378 feat(tickets): explorer engineer dropdown + ticket pulldown (datalist)
Engineer is now a <select> of all engineers; Ticket ID is an input with a
datalist of open ticket ids (pick an open ticket, or type any id for substring
search across history). Both populated once from GET /webhook/inc-filter-options;
cluster select also filled from the full cluster list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:34:48 +03:00
david kiania
a364418df1 feat(tickets): Ticket explorer — query open/closed tickets by id/engineer/cluster/time
Replace the five static bottom panels (closures-daily, closures-by-engineer,
by-status, cluster tables) with a queryable Ticket explorer: filter bar (Ticket
ID, Engineer, Cluster, State [Closed/Open/All], Time [today/week/month/all/
custom]) + a results table (ticket, status, cluster·location, engineer, when,
SLA pill, MTTR). Clicking a row flies the map to that ticket and opens its popup;
rows with no geom are flagged non-locatable. Backed by GET /webhook/inc-search
(fn_inc_search). EAT preset ranges resolved client-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:49:00 +03:00
david kiania
2d7cc98d4b feat(tickets map): auto midnight rollover + larger squircle markers
- Midnight reset for ALL today-windowed categories: the dashboard now refetches
  when the EAT calendar day changes (60s check), so closed pins/counts, the
  day-total, closures-daily and closures-by-engineer all reset for the new day
  even on a screen left open overnight (previously only reset on manual Refresh).
- Markers enlarged (open icon-size max 1.0 -> 1.25, closed 0.78 -> 1.0) — bigger
  than before but still more compact than the old teardrops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:33:33 +03:00
david kiania
8a0e1304ab feat(tickets map): day-total KPI, squircle+bolt markers, new SLA palette
- Top bar: new lead KPI "Total today (open + closed)" = open_now + closed_in_window.
  At midnight closed=0 so it shows the start-of-day backlog, then tracks the day's
  full workload as tickets close.
- Markers: replace the ticket-stub with a squircle + lightning-bolt (incident/fault
  feel; distinct from round vehicle markers), drawn via Path2D.
- SLA palette + labels: Out of SLA = strong maroon red (#A01830), At risk = deep
  yellow (#E0A800), Within SLA = deep purple (#6B21A8); legend/popups relabelled
  "Out of SLA / At risk / Within SLA". Closed stays a pastel of the same colour.

Daily reset is inherent: the closed layer is windowed to today (closed_at >= today),
so after midnight it empties and the map starts with open tickets by live SLA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:14:16 +03:00
david kiania
b653327b0f feat(tickets map): ticket-stub markers instead of generic teardrop
Replace the canvas teardrop pin with an admission-ticket silhouette (side
perforation notches + a dashed tear line, tapering to a bottom point that
anchors on the location) drawn via Path2D at 4x (pixelRatio 4). Keeps every
existing rule: fill = SLA colour, vivid = open / pastel = closed, white outline,
bottom anchor, and the fan-out declutter. Markers now read clearly as tickets
and are distinct from the round vehicle markers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:27:56 +03:00
david kiania
51a84b66c6 feat(tickets map): closures-by-engineer panel, drill-down + dispatcher popup details
- New "Closures by engineer" leaderboard panel (metrics.by_owner): engineer,
  closed, breached, avg MTTR. Clicking an engineer toggles a drill-down that
  filters the closed map pins to only their closures (and ensures the closed
  layer is visible).
- Popups now carry the details dispatchers need: open popups show location_name
  and the true coordinates (copyable; the fan-out keeps the real coord even when
  the pin is offset); closed popups show "closed by <engineer>".

Backed by fn_inc_dashboard migration 12 (owner case-normalized server-side).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:53:50 +03:00
david kiania
a32735bca3 feat(tickets map): fan out co-located pins so faded-closed is visible
Cluster-centroid tickets share an exact coordinate, so the open layer sat on
top of the faded closed pins (23 of 25 closed points were occluded) — the new
closed colouring was correct but invisible. Now pins sharing a coordinate are
fanned into a small deterministic sunflower spiral around it, computed across
BOTH layers so an open and a closed ticket at the same centroid separate.
Active (vivid) and closed (faded) now both show. Also default the Closed layer
on. Offsets are cosmetic (cluster centroids are already approximate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:04:06 +03:00
david kiania
62479c0a72 feat(tickets map): closed tickets render as faded same SLA colour
Previously closed INC tickets all used one flat slate pin, losing the SLA
outcome and reading as inactive/uninformative. Now a closed ticket keeps its
SLA colour but as a light ('pastel') version — Breached → light red, Compliant
→ light green — so active tickets stay vivid and closed ones are a washed-out
same-hue. Makes active-vs-closed and SLA outcome apparent across both layers.

- Faded closed pin images keyed on sla_status (Compliant|Breached, + fallback).
- inc-closed layer matches sla_status → faded pin; popup badge + legend updated
  (new "Closed SLA" key); header subtitle reworded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:01:56 +03:00
david kiania
737f8c3d31 feat(tickets): centre filters with tiles; show only ISP vehicles
- Vertically centre the cluster/status/window filters against the metric tiles
  in the INC overview row.
- Overlay only ISP cost-centre vehicles (the teams handling INC); other cost
  centres are filtered out. Layer count + labels updated to "ISP vehicles".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:37:50 +03:00
david kiania
930a646116 refactor(tickets): move cluster/status/window filters beside the KPIs
Fold the filter controls into the INC overview card, right-aligned next to the
metric tiles, and remove the standalone filter bar — better visual balance
(fills the previously empty right side of the overview row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:29:32 +03:00
david kiania
1ec25b70be refactor(tickets): bigger map, split clusters by region, drop header KPI strip
- Remove the redundant header KPI strip on the Tickets tab (metrics live in the
  INC overview card); header stays empty there.
- Enlarge the map (62vh, min 520px) and size the grid cards to their content
  (#tk-main align-items:start) so there's no stretched empty space under the
  Closures / By-status cards.
- Split the single 26-row "By cluster" list into "Clusters — Nairobi" and
  "Clusters — Mombasa / Voi", classified by cluster name (coast keyword set);
  bottom row is now four compact span3 cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:23:09 +03:00
david kiania
0ed9b6a252 feat(tickets): teardrop pin icons; closed = one slate colour, status ignored
Render INC tickets as canvas-drawn teardrop map pins via MapLibre symbol layers
(scales to thousands of closed features, unlike DOM markers):

- Open pins coloured by SLA state; larger than the old circles for hierarchy.
- Closed pins use a single muted slate colour irrespective of status (the only
  distinction that matters once closed), slightly smaller + under the open layer.
- Legend/popup closed swatch aligned to the new closed colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:05:03 +03:00
david kiania
6504328e58 fix(tickets): keep INC header KPIs from being clobbered by other tabs
Late async loaders (notably the boot-time logistics loadAll) called their KPI
renderer unconditionally, overwriting the shared header strip after the user had
switched to another tab. Guard renderKpis / renderFuelKpis / renderIncKpis so each
only updates the header when its own tab is active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:52:46 +03:00
david kiania
e32ec92cbf feat(tickets): replace INC/CRQ map with INC operations dashboard
Overhaul the Tickets tab into the documented INC operations dashboard,
backed by the new GET /webhook/inc-dashboard endpoint (reporting.fn_inc_dashboard):

- Filterbar (cluster / status / window: today|week|month|custom)
- Metric strip: open now, closed in window, open/closed SLA breakdown,
  avg MTTR, closures/day + freshness
- Live map: open INC coloured by SLA state, dimmed closed overlay,
  FleetNow vehicle markers, layer toggles + SLA legend
- By-status / by-cluster tables + daily closures chart
- Data load decoupled from the basemap so the dashboard renders even if
  WebGL/map init is slow or fails

Removes the old combined INC/CRQ map and the /webhook/tickets call (CRQ deferred).
Adds docs/tickets-inc-{overhaul-plan,implementation-guide}.{md,html}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:42:23 +03:00
kianiadee
f27afcfa9e feat(fuel): add Fuel Log tab (real fills from the fuel feed)
New tab backed by dashboard_api /analytics/fuel-fills(+/recent): KPI strip
(litres, KES spend, fills, KES/L, vehicles), spend+litres daily trend, per-vehicle
table (incl. km/L), by-department breakdown, recent fills. Shares the filter state
plus new department/fuel-type dropdowns; lazy-loads on first open. Recent-fills
time renders the Africa/Nairobi wall-clock value directly (no double tz shift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:01:08 +03:00
david kiania
6bfb72751f merge: tickets map (live vehicles + INC/CRQ layers) into staging 2026-06-11 16:24:16 +03:00
david kiania
59620722cd feat(tickets): replace scaffold with FleetNow-style map (live vehicles + INC/CRQ layers)
- #view-tickets is now a MapLibre map: live vehicle DOM markers (ported from
  FleetNow) + INC (red) / CRQ (blue) ticket circle layers from /webhook/tickets
- Layers toggle with counts; open/all status filter; lazy-init + map.resize()
- Header KPI strip shows INC/CRQ open + vehicles/moving on the Tickets tab
- Logistics analytics tab unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:24:16 +03:00
david kiania
d907ea9425 merge: Logistics/Tickets tabbed navigation into staging 2026-06-11 14:20:09 +03:00
david kiania
2611212fcd feat(ui): add Logistics/Tickets tabbed navigation
- Wrap the existing analytics dashboard as the Logistics tab
- Add a scaffolded Tickets tab (per-tab KPIs, recent-tickets + by-status
  cards, informative empty state)
- Shared header KPI strip swaps per tab; tickets load lazily on first open
- Ticket data source left as a dashboard_api integration point — no S3
  credentials embedded in the static SPA

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 14:19:56 +03:00
david kiania
c2908cc17d chore: remove auto-deploy test marker
Auto-deploy verified (push->Forgejo webhook->Coolify->Traefik). Reverting the
test meta marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:07:36 +03:00
david kiania
71f40e8c62 test: build marker to verify Forgejo->Coolify auto-deploy
Harmless <meta> marker to confirm a push to staging triggers a Coolify
redeploy. Safe to revert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:06:34 +03:00
5 changed files with 1957 additions and 2 deletions

View file

@ -0,0 +1,261 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FleetOps — INC operations dashboard · implementation guide</title>
<style>
:root {
--bg:#161a23; --panel:#1e232e; --panel-2:#232a36; --border:#2c333f;
--text:#ECEFF4; --muted:#93a0b4; --accent:#E8954A; --live:#2dd4a7;
--parked:#6b7280; --warn:#f0a93b; --danger:#ef5b5b; --error-bg:#2a0a0a;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); padding: 0 0 80px;
}
.wrap { max-width: 960px; margin: 0 auto; padding: 0 22px; }
header.doc { background: var(--panel); border-bottom: 1px solid var(--border); padding: 26px 0 22px; margin-bottom: 28px; }
header.doc .wrap { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.mark { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); }
header.doc h1 { font-size: 22px; margin: 0; letter-spacing:.3px; }
header.doc .sub { color: var(--muted); font-size: 13px; width:100%; margin-top:4px; }
h2 { font-size: 18px; margin: 34px 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
h3 { font-size: 15px; margin: 22px 0 8px; color: var(--accent); }
p { margin: 10px 0; }
ul, ol { margin: 10px 0; padding-left: 22px; }
li { margin: 5px 0; }
a { color: var(--accent); }
strong { color: #fff; }
code {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 4px;
padding: 1px 5px; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; color: #f0d9bf;
}
pre {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 14px 16px; overflow: auto; margin: 14px 0;
}
pre code { background: none; border: 0; padding: 0; color: var(--text); white-space: pre; }
hr { border: 0; border-top: 1px solid var(--border); margin: 30px 0; }
blockquote {
margin: 14px 0; padding: 10px 16px; background: rgba(232,149,74,.08);
border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; color: var(--muted);
}
blockquote code { color: #f0d9bf; }
table { width:100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
th, td { text-align:left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing:.5px; }
.pill { display:inline-block; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; padding:2px 9px; border-radius:999px; margin-right:6px; }
.pill.a { background: rgba(232,149,74,.16); color: var(--accent); }
.pill.b { background: rgba(45,212,167,.16); color: var(--live); }
.callout { background: var(--error-bg); border: 1px solid rgba(239,91,91,.4); color:#f3b6b6; border-radius:8px; padding:12px 16px; margin:16px 0; }
.meta { color: var(--muted); font-size: 12.5px; }
</style>
</head>
<body>
<header class="doc">
<div class="wrap">
<span class="mark"></span>
<h1>FleetOps — INC operations dashboard · implementation guide</h1>
<div class="sub">Actionable runbook: replace the combined INC/CRQ Tickets map with the documented INC dashboard. Companion to <code>tickets-inc-overhaul-plan.md</code>. CRQ deferred.</div>
</div>
</header>
<div class="wrap">
<h2>0. Architecture &amp; data flow</h2>
<pre><code>FleetOps SPA (15_fleetops/src/index.html)
│ GET /webhook/inc-dashboard?cluster=&amp;status=&amp;window=&amp;from=&amp;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)</code></pre>
<ul>
<li><strong>Staging API</strong>: <code>https://fleetapi.fivetitude.com</code> (read-only <code>dashboard_ro</code> role, reads the prod DB).</li>
<li><strong>DB</strong>: <code>tracksolid_db</code> on <code>twala.rahamafresh.com:5433</code> (direct psql/psycopg2 via the write <code>DATABASE_URL</code>).</li>
</ul>
<h2>1. Prerequisites &amp; access</h2>
<table>
<thead><tr><th>Need</th><th>Status / how</th></tr></thead>
<tbody>
<tr><td>Write <code>DATABASE_URL</code> to <code>tracksolid_db</code></td><td>Provided by user; export as <code>DATABASE_URL</code> (do <strong>not</strong> commit).</td></tr>
<tr><td>Python + psycopg2</td><td>Use <code>16_fleettickets/.venv</code>.</td></tr>
<tr><td>Deploy access to staging host</td><td>scp + <code>ssh kianiadee@twala.rahamafresh.com</code> (SSH config entry exists).</td></tr>
<tr><td>Source repos</td><td><code>15_fleetops</code> (SPA), <code>tracksolid_timescale_grafana_prod</code> (API), <code>16_fleettickets</code> (migrations/docs).</td></tr>
</tbody>
</table>
<hr />
<h2><span class="pill a">Phase A</span> API endpoint (do this first)</h2>
<h3>Step A1 — Check whether <code>reporting.fn_inc_dashboard</code> is deployed</h3>
<pre><code>cd ~/Downloads/projects/16_fleettickets
source .venv/bin/activate
export DATABASE_URL='postgres://…@twala.rahamafresh.com:5433/tracksolid_db' # provided
python - &lt;&lt;'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</code></pre>
<ul>
<li>Signature printed → <strong>skip A2</strong>, go to A3.</li>
<li><code>None</code> → run A2.</li>
</ul>
<h3>Step A2 — Apply migrations (idempotent, ledgered)</h3>
<pre><code>python run_migrations.py</code></pre>
<p>Applies unapplied <code>migrations/*.sql</code> in order; 0108 are <strong>skipped</strong>.
Expected new: <code>09_inc_dashboard_fn.sql</code> (and <code>10_inc_history_capture.sql</code> if absent).
All migrations are <code>CREATE OR REPLACE</code> / <code>IF NOT EXISTS</code>. Sanity check:</p>
<pre><code>python - &lt;&lt;'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</code></pre>
<h3>Step A3 — Add the <code>/webhook/inc-dashboard</code> handler</h3>
<p>File: <code>tracksolid_timescale_grafana_prod/dashboard_api_rev.py</code>. Mirror the existing
<code>tickets()</code> handler (<code>:275</code>). Reuse <code>get_conn</code>, <code>_clean</code>, <code>log</code>.</p>
<ol>
<li>Add <code>Query</code> to the FastAPI import (~line 46): <code>from fastapi import FastAPI, Request, Query</code></li>
<li>Add the handler near <code>tickets()</code>:</li>
</ol>
<pre><code>_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."}})</code></pre>
<ul>
<li><code>datetime</code> is already imported. Leave the legacy <code>/webhook/tickets</code> handler untouched.</li>
</ul>
<h3>Step A4 — Deploy to staging</h3>
<pre><code>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'</code></pre>
<p>The script stages the file and <strong>recreates</strong> the <code>dashboard_api_staging</code> container
(CORS already allows <code>https://fleetops.fivetitude.com</code>).</p>
<h3>Step A5 — Verify the endpoint</h3>
<pre><code>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</code></pre>
<p>Cross-check <code>metrics.open_now</code> against <code>SELECT count(*) FROM tickets.inc WHERE is_actionable</code>.</p>
<hr />
<h2><span class="pill b">Phase B</span> SPA overhaul (<code>15_fleetops/src/index.html</code>)</h2>
<h3>Step B1 — Erase the existing INC/CRQ view</h3>
<ul>
<li>Delete the full-bleed <code>#view-tickets</code> map section (markup, ~lines 374390).</li>
<li>Remove <code>loadTickets()</code> (<code>/webhook/tickets</code>), the <strong>CRQ</strong> circle layer, combined summary handling, <code>TICKET_COLORS</code>, <code>ticketStats.crq</code>, old <code>showTicketPopup()</code>.</li>
<li>Keep the map/marker/popup CSS (~lines 182252) and the warm-dark palette.</li>
</ul>
<h3>Step B2 — Reuse (do NOT reinvent)</h3>
<p>Vehicle overlay machinery stays: <code>loadLive()</code> (<code>/webhook/live-positions</code>, 15s poll),
<code>upsertVeh()</code>, <code>showVehPopup()</code>, <code>vehState()</code>, <code>ccColor()</code>, <code>pastel()</code>,
<code>plateTail()</code>, <code>BASEMAP</code>, <code>COST_CENTRE_COLORS</code>, <code>CC_PALETTE</code>, <code>escapeHtml</code>,
<code>updateVehScale()</code>, the layers-panel builder. Reuse the Logistics/Fuel filterbar + <code>.card</code>/<code>.span*</code>
grid + <code>num()</code>/<code>intg()</code> + custom-range show/hide (<code>:467</code>).</p>
<h3>Step B3 — New <code>#view-tickets</code> markup (dashboard cards + map)</h3>
<ul>
<li><strong>Filterbar:</strong> Cluster · Status · Window (Today / This week / This month / Custom) + custom dates · Apply · ↻.</li>
<li><strong><code>&lt;main&gt;</code> 12-col grid:</strong> metric cards (Open now, Closed in window, Open SLA breached/at-risk/ok/unknown, Closed SLA compliant/breached, Avg MTTR min→h, Closure rate <code>per_day_avg</code> + Chart.js sparkline from <code>closure_rate.series</code>); Map card <code>.span12</code> with layer toggles + SLA legend; By status + By cluster tables <code>.span6</code> each; Freshness line.</li>
</ul>
<h3>Step B4 — New JS (INC data + map)</h3>
<ul>
<li><code>incQs()</code> → query string; <code>loadInc()</code><code>fetch(${API_BASE}/webhook/inc-dashboard?…)</code>.</li>
<li>Populate Cluster/Status dropdowns from the first unfiltered response's <code>metrics.by_cluster</code> / <code>metrics.by_status</code> keys.</li>
<li><strong>Open INC</strong> — circle colored by <code>sla_state</code> (breached=<code>--danger</code>, at_risk=<code>--warn</code>, ok=<code>--live</code>, unknown=<code>--parked</code>); data = <code>open.features</code>.</li>
<li><strong>Closed INC</strong> — dimmed/hollow grey; data = <code>closed.features</code>; toggle (default off).</li>
<li><strong>Vehicles</strong> — existing DOM markers via <code>loadLive()</code>.</li>
<li><strong>Popups:</strong> open → ticket_id, normalized_status, cluster·region, assigned_team/owner, sla_state + hours_open, geo_source ("approx — cluster" when <code>geo_source==='cluster'</code>); closed → add closed_at, mttr (min→h), sla_status.</li>
<li>Repurpose <code>renderTicketKpis()</code><code>renderIncKpis()</code>; <code>switchTab('tickets')</code><code>initIncMap()</code> + lazy <code>loadInc()</code>. Apply/↻/window-change → <code>loadInc()</code>; keep the 15s vehicle poll.</li>
<li><strong>Caveat:</strong> drive the map from <code>*.features</code>, drive cards/tables from <code>metrics</code> (<code>open.features.length</code> may be <code>&lt; metrics.open_now</code>).</li>
</ul>
<h3>Step B5 — Verify the SPA locally</h3>
<pre><code>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</code></pre>
<p>Confirm: cards + header KPIs populate; by-status/by-cluster match <code>metrics</code>; 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 <code>/webhook/inc-dashboard</code> + <code>/webhook/live-positions</code>.</p>
<hr />
<h2>Rollback</h2>
<ul>
<li><strong>API:</strong> additive route — remove the handler and redeploy to revert; <code>/webhook/tickets</code> unchanged. DB migrations are forward-only but idempotent and unused by the old path.</li>
<li><strong>SPA:</strong> single file under git — revert <code>src/index.html</code>.</li>
</ul>
<h2>Out of scope (future)</h2>
<ul>
<li><strong>CRQ</strong> rebuild (same pattern once a CRQ feed/function exists).</li>
<li>Open-backlog-over-time / observed transitions (needs <code>16_fleettickets</code> history capture).</li>
<li>Nearest-vehicle dispatch off <code>geog</code>.</li>
</ul>
<p class="meta">Implementation runbook for the FleetOps Tickets → INC overhaul.</p>
</div>
</body>
</html>

View file

@ -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 (0108) are
**skipped**. Expected new: `09_inc_dashboard_fn.sql` (and `10_inc_history_capture.sql`
if not yet present). All migrations are `CREATE OR REPLACE` / `IF NOT EXISTS`.
- Sanity check the function returns valid JSON:
```bash
python - <<'PY'
import os, json, psycopg2
c = psycopg2.connect(os.environ["DATABASE_URL"]); cur = c.cursor()
cur.execute("SELECT reporting.fn_inc_dashboard()")
d = cur.fetchone()[0]
print("keys:", list(d.keys()))
print("window:", d["window"])
print("open feats:", len(d["open"]["features"]), " closed feats:", len(d["closed"]["features"]))
print("metrics.open_now:", d["metrics"]["open_now"], " closed_in_window:", d["metrics"]["closed_in_window"])
PY
```
### Step A3 — Add the `/webhook/inc-dashboard` handler
File: `tracksolid_timescale_grafana_prod/dashboard_api_rev.py`. Mirror the existing
`tickets()` handler (`:275`). Reuse `get_conn`, `_clean`, `log`.
1. Add `Query` to the FastAPI import (line ~46):
```python
from fastapi import FastAPI, Request, Query
```
2. Add the handler (place near the `tickets()` endpoint):
```python
_INC_WINDOWS = {"today", "week", "month", "custom"}
@app.get("/webhook/inc-dashboard")
def inc_dashboard(
cluster: str | None = None,
status: str | None = None,
window: str = "today",
from_: str | None = Query(None, alias="from"),
to: str | None = None,
):
# --- validation (contract) ---
if window not in _INC_WINDOWS:
return JSONResponse({"error": {"type": "bad_request",
"message": "window must be one of today|week|month|custom"}}, status_code=400)
f, t = _clean(from_), _clean(to)
if window == "custom" and not f and not t:
return JSONResponse({"error": {"type": "bad_request",
"message": "custom window requires from and/or to"}}, status_code=400)
def _parse(v):
if not v: return None
try: return datetime.fromisoformat(v)
except ValueError: return False
pf, pt = _parse(f), _parse(t)
if pf is False or pt is False:
return JSONResponse({"error": {"type": "bad_request",
"message": "from/to must be ISO-8601 with offset"}}, status_code=400)
if pf and pt and pf >= pt:
return JSONResponse({"error": {"type": "bad_request",
"message": "from must be < to"}}, status_code=400)
# --- one passthrough call ---
try:
with get_conn() as conn, conn.cursor() as cur:
cur.execute(
"SELECT reporting.fn_inc_dashboard(%s, %s, %s, %s, %s)",
(_clean(cluster), _clean(status), window, f, t),
)
payload = cur.fetchone()[0] or {}
return JSONResponse(payload) # JSON body unchanged
except Exception:
log.exception("inc-dashboard failed")
return JSONResponse({"error": {"type": "unknown",
"message": "INC dashboard is unavailable. Try again in a few seconds."}})
```
- `datetime` is already imported (`from datetime import …`).
- Leave the legacy `/webhook/tickets` handler untouched (CRQ / fallback).
### Step A4 — Deploy to staging
```bash
cd ~/Downloads/projects/tracksolid_timescale_grafana_prod
scp dashboard_api_rev.py kianiadee@twala.rahamafresh.com:~/dashboard_api_staging_rev.py
scp deploy_dashboard_api_staging.sh kianiadee@twala.rahamafresh.com:~/
ssh kianiadee@twala.rahamafresh.com 'bash ~/deploy_dashboard_api_staging.sh'
```
The script stages the file into the mount and **recreates** the `dashboard_api_staging`
container (CORS already allows `https://fleetops.fivetitude.com`).
### Step A5 — Verify the endpoint
```bash
B=https://fleetapi.fivetitude.com
curl -s "$B/webhook/inc-dashboard" | head -c 400; echo # 200, today
curl -s "$B/webhook/inc-dashboard?window=month" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["metrics"])'
curl -s "$B/webhook/inc-dashboard?status=ACCEPTED" | python3 -c 'import sys,json;d=json.load(sys.stdin);print("open",d["metrics"]["open_now"])'
curl -s -o /dev/null -w "%{http_code}\n" "$B/webhook/inc-dashboard?window=bogus" # 400
curl -s -o /dev/null -w "%{http_code}\n" "$B/webhook/inc-dashboard?window=custom" # 400
```
Cross-check `metrics.open_now` against `SELECT count(*) FROM tickets.inc WHERE is_actionable`.
---
## Phase B — SPA overhaul (`15_fleetops/src/index.html`, single file)
### Step B1 — Erase the existing INC/CRQ view
- Delete the full-bleed `#view-tickets` map section (markup, ~lines 374390).
- Remove `loadTickets()` (calls `/webhook/tickets`), the **CRQ** circle layer, the
combined INC/CRQ summary handling, `TICKET_COLORS`, `ticketStats.crq`, and the old
`showTicketPopup()` (rebuilt for INC below).
- Keep the map/marker/popup CSS (~lines 182252) and the warm-dark palette.
### Step B2 — Reuse (do NOT reinvent)
Vehicle overlay machinery stays: `loadLive()` (`/webhook/live-positions`, 15s poll),
`upsertVeh()`, `showVehPopup()`, `vehState()`, `ccColor()`, `pastel()`, `plateTail()`,
`BASEMAP`, `COST_CENTRE_COLORS`, `CC_PALETTE`, `escapeHtml`, `updateVehScale()`, the
layers-panel builder. Reuse the Logistics/Fuel filterbar + `.card`/`.span*` grid +
`num()`/`intg()` + custom-range show/hide (`:467`).
### Step B3 — New `#view-tickets` markup (dashboard cards + map)
- **Filterbar:** `Cluster` select · `Status` select · `Window` select
(Today / This week / This month / Custom) + custom start/end dates · `Apply` · `↻`.
- **`<main>` 12-col grid:**
- Metric cards: **Open now**, **Closed in window**, **Open SLA**
(breached/at-risk/ok/unknown), **Closed SLA** (compliant/breached), **Avg MTTR**
(min→h), **Closure rate** (`per_day_avg` + Chart.js sparkline from `closure_rate.series`).
- **Map card** (`.span12`, tall) with layer toggles + SLA legend.
- **By status** + **By cluster** tables (`.span6` each).
- **Freshness** line (exported_at / records_ingested / ingested_at).
### Step B4 — New JS (INC data + map)
- `incQs()` → query string; `loadInc()``fetch(${API_BASE}/webhook/inc-dashboard?…)`.
- Populate Cluster/Status dropdowns from the first unfiltered response's
`metrics.by_cluster` / `metrics.by_status` keys (no dedicated filters endpoint).
- **Layers** (GeoJSON sources):
- **Open INC** — circle colored by `sla_state`: breached=`--danger`, at_risk=`--warn`,
ok=`--live`, unknown=`--parked`; data = `open.features`.
- **Closed INC** — dimmed/hollow grey; data = `closed.features`; toggle (default off).
- **Vehicles** — existing DOM markers via `loadLive()`.
- **Popups:** open → ticket_id, normalized_status, cluster·region, assigned_team/owner,
sla_state + hours_open, geo_source ("approx — cluster" when `geo_source==='cluster'`).
closed → add closed_at, mttr (min→h), sla_status.
- Repurpose `renderTicketKpis()``renderIncKpis()` (Open now / Breached / Closed in
window / Avg MTTR). `switchTab('tickets')``initIncMap()` + lazy `loadInc()`.
- `Apply`/`↻`/window-change → `loadInc()`; keep the 15s vehicle poll.
- **Caveat:** drive the **map** from `*.features`, drive **cards/tables** from
`metrics` (`open.features.length` may be `< metrics.open_now` for un-geocoded rows).
### Step B5 — Verify the SPA locally
```bash
cd ~/Downloads/projects/15_fleetops/src
python3 -m http.server 8080 # API_BASE defaults to https://fleetapi.fivetitude.com
# open http://localhost:8080 → Tickets tab
```
Confirm: cards + header KPIs populate; by-status/by-cluster match `metrics`; open INC
SLA-colored + vehicles render; Closed INC toggle overlays the windowed set; changing
Cluster/Status/Window + Apply refetches; popups show documented fields; the network tab
shows only `/webhook/inc-dashboard` + `/webhook/live-positions` (no `/webhook/tickets`).
---
## Rollback
- **API:** the change is additive (new route). To revert, remove the handler and
redeploy; the legacy `/webhook/tickets` is unchanged. DB migrations are forward-only
but idempotent and unused by the old path.
- **SPA:** single file under git — revert `src/index.html`.
## Out of scope (future)
- **CRQ** rebuild (same pattern once a CRQ feed/function exists).
- Open-backlog-over-time / observed transitions (needs `16_fleettickets` history capture).
- Nearest-vehicle dispatch off `geog`.

View file

@ -0,0 +1,302 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FleetOps — Tickets → INC operations dashboard (overhaul plan)</title>
<style>
:root {
--bg:#161a23; --panel:#1e232e; --panel-2:#232a36; --border:#2c333f;
--text:#ECEFF4; --muted:#93a0b4; --accent:#E8954A; --live:#2dd4a7;
--parked:#6b7280; --warn:#f0a93b; --danger:#ef5b5b;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); padding: 0 0 80px;
}
.wrap { max-width: 920px; margin: 0 auto; padding: 0 22px; }
header.doc {
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 26px 0 22px; margin-bottom: 30px;
}
header.doc .wrap { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.mark { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); }
header.doc h1 { font-size: 22px; margin: 0; letter-spacing:.3px; }
header.doc .sub { color: var(--muted); font-size: 13px; width:100%; margin-top:4px; }
h2 {
font-size: 18px; margin: 34px 0 12px; padding-bottom: 8px;
border-bottom: 1px solid var(--border); color: var(--text);
}
h3 { font-size: 15px; margin: 22px 0 8px; color: var(--accent); }
p { margin: 10px 0; }
ul { margin: 10px 0; padding-left: 22px; }
li { margin: 5px 0; }
a { color: var(--accent); }
strong { color: #fff; }
code {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 4px;
padding: 1px 5px; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
color: #f0d9bf;
}
pre {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 14px 16px; overflow: auto; margin: 14px 0;
}
pre code { background: none; border: 0; padding: 0; color: var(--text); }
hr { border: 0; border-top: 1px solid var(--border); margin: 30px 0; }
blockquote {
margin: 14px 0; padding: 10px 16px; background: rgba(232,149,74,.08);
border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; color: var(--muted);
}
blockquote code { color: #f0d9bf; }
.callout {
background: var(--error-bg, #2a0a0a); border: 1px solid rgba(239,91,91,.45);
color: #f3b6b6; border-radius: 8px; padding: 12px 16px; margin: 16px 0;
}
.callout strong { color: var(--danger); }
.pill {
display:inline-block; font-size:11px; font-weight:700; text-transform:uppercase;
letter-spacing:.5px; padding:2px 9px; border-radius:999px; margin-right:6px;
}
.pill.a { background: rgba(232,149,74,.16); color: var(--accent); }
.pill.b { background: rgba(45,212,167,.16); color: var(--live); }
.meta { color: var(--muted); font-size: 12.5px; }
</style>
</head>
<body>
<header class="doc">
<div class="wrap">
<span class="mark"></span>
<h1>FleetOps — Tickets → INC operations dashboard</h1>
<div class="sub">Implementation plan · erase existing INC/CRQ view, rebuild INC first · endpoint-first, dashboard-cards layout</div>
</div>
</header>
<div class="wrap">
<h2>Context</h2>
<p>The FleetOps SPA's <strong>Tickets</strong> tab is currently a full-bleed MapLibre map showing
combined <strong>INC (red) + CRQ (blue)</strong> ticket circles plus live FleetNow vehicles, fed by
the legacy <code>GET /webhook/tickets</code> (→ <code>reporting.fn_tickets_for_map</code>). Meanwhile,
the <code>16_fleettickets</code> repo has designed and documented a richer <strong>INC operations
dashboard</strong> (Phase 2): an open-ticket layer + windowed closed overlay + derived SLA states +
ticket metric cards, served by a new <code>reporting.fn_inc_dashboard(...)</code> function and exposed
at <code>GET /webhook/inc-dashboard</code>.</p>
<p>We are overhauling the SPA to that documented design. Per the user: <strong>erase the existing
INC + CRQ ticket view and rebuild INC first</strong> (CRQ deferred). INC is fully documented; CRQ
reuses the same machinery later.</p>
<div class="callout">
<strong>Key blocker found:</strong> <code>GET /webhook/inc-dashboard</code> currently <strong>404s</strong>
the DB function lives in <code>16_fleettickets/migrations/09_inc_dashboard_fn.sql</code> but the HTTP
wrapper is not in the <code>dashboard_api</code> service. The legacy <code>/webhook/tickets</code> returns
200 with live INC+CRQ data (INC ingest is live: 21,301 records, freshness current).
</div>
<p><strong>Decisions (confirmed with user):</strong></p>
<ul>
<li><strong>Endpoint first, then SPA</strong> — build/verify the API endpoint (+ DB function) and
confirm it returns real data, <em>then</em> overhaul the SPA against the live endpoint.</li>
<li><strong>Layout:</strong> 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.</li>
</ul>
<h3>Reference docs (source of truth)</h3>
<ul>
<li><code>16_fleettickets/docs/dashboard-api-contract.md</code> — endpoint params, response shape,
field semantics (mttr = minutes, sla_state derived, coords <code>[lng,lat]</code>, map-vs-metrics gap).</li>
<li><code>16_fleettickets/docs/phase-2-dashboard.md</code><code>fn_inc_dashboard</code> signature + metrics.</li>
</ul>
<hr />
<h2><span class="pill a">Phase A</span> API endpoint — repo <code>tracksolid_timescale_grafana_prod</code></h2>
<blockquote>
File: <code>~/Downloads/projects/tracksolid_timescale_grafana_prod/dashboard_api_rev.py</code>.
Deployed by scp + ssh to the remote host; the <strong>staging</strong> instance
(<code>fleetapi.fivetitude.com</code>) runs read-only as <code>dashboard_ro</code>. These steps touch
a live server and may need the user to run the scp/ssh deploy via <code>! &lt;cmd&gt;</code>.
</blockquote>
<h3>A1. Verify / apply the DB function</h3>
<ul>
<li>Confirm <code>reporting.fn_inc_dashboard</code> exists in the live DB. If absent, apply via
<code>16_fleettickets/run_migrations.py</code> (needs the <strong>write</strong> <code>DATABASE_URL</code>;
applies <code>09_inc_dashboard_fn.sql</code>, and <code>08</code>/<code>10</code> if not already in
<code>tickets.schema_migrations</code>). Migrations are idempotent + ledgered, so re-running is safe.</li>
<li>Sanity check in psql: <code>SELECT reporting.fn_inc_dashboard();</code> → valid JSON (open/closed
FeatureCollections, metrics, <code>window.preset='today'</code>, freshness).</li>
</ul>
<h3>A2. Add the <code>/webhook/inc-dashboard</code> handler</h3>
<p>Mirror the existing <code>tickets()</code> handler (<code>dashboard_api_rev.py:275-304</code>): one
passthrough SQL call, JSON body returned unchanged. Reuse <code>get_conn</code>, <code>_clean</code>.</p>
<pre><code>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."}})</code></pre>
<ul>
<li>Pass <code>from</code>/<code>to</code> as ISO-8601 strings; PostgreSQL casts text →
<code>timestamptz</code> on the function call. Validate parseability API-side
(e.g. <code>datetime.fromisoformat</code>) to return clean <code>400</code>s rather than a 500 from the DB.</li>
<li>Leave the legacy <code>/webhook/tickets</code> handler in place (CRQ / fallback may use it).</li>
</ul>
<h3>A3. Deploy + verify</h3>
<ul>
<li>Deploy to staging: scp <code>dashboard_api_rev.py</code> → host, scp the staging deploy script,
<code>ssh … bash ~/deploy_dashboard_api_staging.sh</code> (recreates the container).</li>
<li>Verify against <code>https://fleetapi.fivetitude.com</code>:
<ul>
<li><code>GET /webhook/inc-dashboard</code> → 200, documented shape, <code>open</code>/<code>closed</code> FCs.</li>
<li><code>?window=month</code>, <code>?cluster=MUIGAI%20INN</code>, <code>?status=ACCEPTED</code>,
<code>?from=…%2B03:00&amp;to=…%2B03:00</code> → counts sane; <code>open</code> not time-filtered.</li>
<li><code>?window=bogus</code> → 400; <code>?window=custom</code> (no from/to) → 400.</li>
</ul>
</li>
</ul>
<hr />
<h2><span class="pill b">Phase B</span> SPA overhaul — <code>15_fleetops/src/index.html</code> (single file)</h2>
<h3>B1. Erase the existing INC/CRQ view</h3>
<p>Remove from <code>src/index.html</code>:</p>
<ul>
<li><strong>Markup:</strong> the full-bleed map section <code>#view-tickets</code> (lines ~374-390).</li>
<li><strong>JS — drop:</strong> <code>loadTickets()</code> (calls <code>/webhook/tickets</code>), the
<strong>CRQ</strong> circle layer, combined INC/CRQ summary handling, <code>showTicketPopup()</code>
(rebuild for INC), <code>TICKET_COLORS</code>, <code>ticketStats.crq</code>.</li>
<li><strong>CSS:</strong> keep the map/marker/popup blocks (lines ~182-252) — reused; rename
<code>#tk-*</code> selectors only if the new markup changes ids.</li>
</ul>
<h3>B2. Keep + reuse (do NOT reinvent)</h3>
<p>The vehicle overlay machinery stays — the contract says the SPA overlays FleetNow:</p>
<ul>
<li><code>loadLive()</code> (<code>/webhook/live-positions</code>, 15s poll), <code>upsertVeh()</code>,
<code>showVehPopup()</code>, <code>vehState()</code>, <code>ccColor()</code>, <code>pastel()</code>,
<code>plateTail()</code>, <code>BASEMAP</code>, <code>COST_CENTRE_COLORS</code>, <code>CC_PALETTE</code>,
<code>escapeHtml</code>, <code>updateVehScale()</code>, <code>initTicketsMap()</code>
(rename → <code>initIncMap()</code>), the layers-panel builder, the MapLibre popup CSS, and the warm-dark palette.</li>
<li>Filterbar markup/behaviour pattern from the Logistics/Fuel tabs (<code>.filterbar</code>,
custom-range show/hide at <code>index.html:467-471</code>, <code>.card</code>/<code>.span*</code> grid,
table renderers, <code>num()</code>/<code>intg()</code>).</li>
</ul>
<h3>B3. New markup — <code>#view-tickets</code> (dashboard cards + map)</h3>
<ul>
<li><strong>Filterbar:</strong> <code>Cluster</code> select, <code>Status</code> select,
<code>Window</code> select (Today / This week / This month / Custom) + custom start/end date inputs
(reuse the <code>.ff.custom</code> show/hide), <code>Apply</code>, refresh <code></code>.</li>
<li><strong><code>&lt;main&gt;</code> 12-col grid:</strong>
<ul>
<li>Metric cards row: <strong>Open now</strong>, <strong>Closed in window</strong>,
<strong>Open SLA</strong> (breached / at-risk / ok / unknown), <strong>Closed SLA</strong>
(compliant / breached), <strong>Avg MTTR</strong> (minutes → show as h), <strong>Closure rate</strong>
(<code>per_day_avg</code> + a small Chart.js sparkline from <code>closure_rate.series</code>).</li>
<li><strong>Map card</strong> (<code>.span12</code>, tall): MapLibre map with layer toggles + SLA legend.</li>
<li><strong>By status</strong> table + <strong>By cluster</strong> table (<code>.span6</code> each)
from <code>metrics.by_status</code> / <code>metrics.by_cluster</code>.</li>
<li><strong>Freshness</strong> line (exported_at / records_ingested / ingested_at) under the map.</li>
</ul>
</li>
</ul>
<h3>B4. New JS — INC data + map</h3>
<ul>
<li><strong>State:</strong> <code>incQs()</code> builds query (<code>cluster</code>, <code>status</code>,
<code>window</code>, and <code>from</code>/<code>to</code> when custom). <code>loadInc()</code>
<code>fetch(${API_BASE}/webhook/inc-dashboard?…)</code>.</li>
<li><strong>Dropdowns:</strong> populate <code>Cluster</code> / <code>Status</code> from the first
unfiltered response's <code>metrics.by_cluster</code> / <code>metrics.by_status</code> keys (no
dedicated filters endpoint exists); keep stable thereafter.</li>
<li><strong>Map layers</strong> on one or two GeoJSON sources:
<ul>
<li><strong>Open INC</strong> — circle layer colored by <code>sla_state</code>
(<code>breached</code>=<code>--danger</code>, <code>at_risk</code>=<code>--warn</code>,
<code>ok</code>=<code>--live</code>, <code>unknown</code>=<code>--parked</code>); data = <code>open.features</code>.</li>
<li><strong>Closed INC</strong> — distinct dimmed style (e.g. hollow grey), data =
<code>closed.features</code>; toggleable (default off).</li>
<li><strong>Vehicles</strong> — existing DOM markers via <code>loadLive()</code>.</li>
<li>Layer panel: Open INC / Closed INC / Vehicles toggles + SLA color legend.</li>
</ul>
</li>
<li><strong>Popups:</strong> open → <code>ticket_id</code>, <code>normalized_status</code>,
<code>cluster · region</code>, <code>assigned_team</code>/<code>owner</code>, <code>sla_state</code> +
<code>hours_open</code>, <code>geo_source</code> (note "approx — cluster" when
<code>geo_source==='cluster'</code>). closed → add <code>closed_at</code>, <code>mttr</code> (min→h),
<code>sla_status</code>.</li>
<li><strong>Header KPI strip:</strong> repurpose <code>renderTicketKpis()</code>
<code>renderIncKpis()</code> showing INC metrics (Open now, Breached, Closed in window, Avg MTTR).
Update <code>switchTab()</code> so the <code>tickets</code> case calls <code>initIncMap()</code> +
<code>loadInc()</code> (lazy, like Fuel).</li>
<li><strong>Filters:</strong> <code>Apply</code>/<code></code>/window-change → <code>loadInc()</code>.
Keep the 15s vehicle poll; <code>loadInc()</code> is on-demand (open layer changes at most hourly).</li>
<li><strong>Caveat to honor:</strong> <code>open.features.length</code> may be <code>&lt; metrics.open_now</code>
(un-geocoded rows) — drive map from <code>features</code>, drive cards/tables from <code>metrics</code>.</li>
</ul>
<hr />
<h2>Verification (end-to-end)</h2>
<ol>
<li><strong>API (Phase A):</strong> curl matrix above against <code>fleetapi.fivetitude.com</code>
shapes, filters, 400s. Compare <code>metrics.open_now</code> to
<code>SELECT count(*) FROM tickets.inc WHERE is_actionable</code> (and <code>inc_open_sla</code> SLA distribution).</li>
<li><strong>SPA (Phase B):</strong> serve <code>src/</code> locally
(<code>python3 -m http.server</code> in <code>src/</code>, or the Caddy Docker image) with
<code>API_BASE=https://fleetapi.fivetitude.com</code>. Open the <strong>Tickets</strong> tab and confirm:
<ul>
<li>Metric cards + header KPIs populate; by-status / by-cluster tables match <code>metrics</code>.</li>
<li>Map shows SLA-colored open INC + live vehicles; toggling Closed INC overlays the windowed
closed set; SLA legend correct.</li>
<li>Changing Cluster / Status / Window + Apply refetches and updates cards, tables, and both
layers; custom range shows date inputs and bounds the closed overlay.</li>
<li>Hover popups show the documented fields (open vs closed).</li>
<li>No console calls to <code>/webhook/tickets</code>; only <code>/webhook/inc-dashboard</code> +
<code>/webhook/live-positions</code>.</li>
</ul>
</li>
</ol>
<h2>Out of scope (future)</h2>
<ul>
<li><strong>CRQ</strong> rebuild (deferred; reuses the same pattern once a CRQ feed/function exists).</li>
<li>Open-backlog-over-time / observed transitions (needs <code>16_fleettickets</code> history capture —
not built). Nearest-vehicle dispatch off <code>geog</code>.</li>
</ul>
<p class="meta">Generated as the implementation plan for the FleetOps Tickets → INC overhaul.</p>
</div>
</body>
</html>

View file

@ -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 `! <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`.
```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 `↻`.
- **`<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. **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`.

File diff suppressed because it is too large Load diff