fleetops/docs/tickets-inc-implementation-guide.html
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

261 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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