Compare commits

..

No commits in common. "fix/spa-xss-and-csp" and "main" have entirely different histories.

9 changed files with 11 additions and 2129 deletions

View file

@ -8,19 +8,6 @@
root * /srv root * /srv
encode zstd gzip encode zstd gzip
# Security headers (FO-SEC-03). CSP allows self + the two pinned CDNs, the
# CARTO basemap (styles/tiles/fonts) and the fleet APIs; SRI in index.html
# pins the CDN payloads themselves. frame-ancestors 'none' = no clickjacking.
# script-src keeps 'unsafe-inline' because the whole app is one inline
# <script> block the CSP's job here is limiting external script origins
# and exfil targets (connect-src), not inline policy.
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: blob: https://*.cartocdn.com; font-src https://*.cartocdn.com; connect-src 'self' https://fleetapi.rahamafresh.com https://fleetapi.fivetitude.com https://*.cartocdn.com; worker-src blob:; frame-ancestors 'none'"
-Server
}
# Health endpoint for Coolify / Traefik probes. # Health endpoint for Coolify / Traefik probes.
handle /healthz { handle /healthz {
respond "ok" 200 respond "ok" 200

View file

@ -1,67 +0,0 @@
# FleetOps — Platform Audit Report (2026-07-02)
Part of the 2026-07-02 cross-repo audit (tracksolid + fleettickets + fleetops; see
the tracksolid repo's `docs/reports/260702_platform_audit_report.md` for the
DB/host-wide findings). Scope here: the SPA (`src/index.html` + `src/env.js`),
the Caddy/Docker serving layer, and the two deployed containers on twala.
Companion docs: `260702_fix_plan.md`, `260702_work_done.md`.
---
## High
### FO-SEC-01 — Stored XSS: API strings rendered into `innerHTML` unescaped
The tickets explorer and map popups escape correctly (`escapeHtml`), but the
**logistics tables (`renderVehicles`, `renderDrivers`), the fuel banner notes, and
all three Fuel Log tables (`renderFuelVehicles`, `renderFuelDepartments`,
`renderFuelRecent`)** interpolate API strings straight into `innerHTML`. The Fuel
Log fields (`driver`, `department`, `fuel_type`, `plate`) originate from **WhatsApp
messages** — genuinely user-generated content — and vehicle/driver names come from
the Tracksolid registry, editable by any fleet-console user. A crafted name like
`<img src=x onerror=…>` executes in every dispatcher's browser. Error banners also
render `e.message` unescaped (server-influenced text).
### FO-SEC-02 — CDN scripts loaded without Subresource Integrity
Chart.js (jsdelivr) and MapLibre (unpkg) load with no `integrity`/`crossorigin`
attributes — a compromised or MITM'd CDN response executes arbitrary script with
access to the fleet APIs.
### FO-SEC-03 — No security headers from Caddy
No `Content-Security-Policy`, `X-Content-Type-Options`, `Referrer-Policy`, or
frame-ancestors protection. Combined with FO-SEC-01 this leaves injected markup
free to load external script and exfiltrate anywhere.
## Medium
### FO-BUG-01 — `loadLive()` parses the response without checking `r.ok`
A 4xx/5xx from `/webhook/live-positions` throws inside `r.json()` on non-JSON
bodies with an unhelpful error; harmless but noisy.
### FO-BUG-02 — Live-position poll never pauses
The 15-second `/webhook/live-positions` poll starts when the Tickets map is first
opened and then runs forever — including when the user is on the Logistics/Fuel
tabs and when the browser tab is hidden overnight. A dashboard left open ≈ 5,760
wasted API calls/day per viewer.
### FO-OPS-01 — Fallback API base pointed at *staging*
`index.html` fell back to `https://fleetapi.fivetitude.com` when `env.js` isn't
templated. CLAUDE.md documents the intended fallback as the **prod** API. A prod
pod missing `API_BASE` would silently lean on the staging bridge (and fail CORS
confusingly).
## Notes / by design
- **FO-OPS-02** — the prod container (`API_BASE=fleetapi.rahamafresh.com`) runs
commit `21bca24`, ~19 commits behind HEAD — this is the documented frozen-prod
staging-umbrella pattern, not drift. But note: **promotion must pair the SPA
deploy with the prod `dashboard_api` bridge redeploy** — prod's bridge currently
lacks all `/webhook/{inc,crq}-*` and `/analytics/fuel-fills` routes the newer
SPA calls (see the tracksolid audit, OPS-01).
- **FO-OPS-03** — untracked scratch files at the repo root
(`marker-preview.html`, `tracksolid_db_connection.md`, `webook_instructions.txt`).
`tracksolid_db_connection.md` documents the public `twala:5433` superuser
connection pattern that the tracksolid audit is eliminating — update or remove
once the SSH-tunnel workflow lands.
- The serving layer is otherwise good: Caddyfile validated at build time,
healthcheck endpoint, runtime `API_BASE` injection via `templates`, `no-store`
on the shell and env.js.

View file

@ -1,27 +0,0 @@
# FleetOps — Fix Plan (2026-07-02)
Companion to `260702_audit_report.md` and `260702_work_done.md`.
## Phase A — repo changes (implemented in this session)
| Step | Finding | Change |
|---|---|---|
| A1 | FO-SEC-01 | Hoist `escapeHtml` into the HELPERS section, add an `esc()` convenience (escapes or em-dashes), and route every API string in the logistics + fuel renderers and the three error banners through it. The tickets code already escaped — now the whole file is consistent. |
| A2 | FO-SEC-02 | Add `integrity="sha384-…" crossorigin="anonymous"` to the three CDN assets (hashes computed from the exact pinned files; recompute command documented inline). |
| A3 | FO-SEC-03 | Caddyfile: `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `frame-ancestors 'none'`, and a CSP restricting script/style/img/font/connect origins to self + the two pinned CDNs + CARTO + the two fleet APIs. `script-src` keeps `'unsafe-inline'` (the app is one inline script block); the CSP's value here is limiting external script origins and exfil targets. |
| A4 | FO-BUG-01/02 | `loadLive()` checks `r.ok`, and skips while `document.hidden` or when the Tickets tab isn't active; switching back to Tickets (or the page becoming visible) triggers an immediate refresh so markers don't wait out the 15 s interval. |
| A5 | FO-OPS-01 | Fallback API base flipped to the prod bridge (`fleetapi.rahamafresh.com`), matching the documented design. |
## Phase B — operational (needs operator decision)
| # | Item |
|---|---|
| B1 | Promotion pairing: when this branch is promoted to prod (Coolify deploy of fleetops.rahamafresh.com), redeploy the prod `dashboard_api` bridge first — it currently lacks the `/webhook/{inc,crq}-*` + `/analytics/fuel-fills` routes the SPA calls. |
| B2 | Clean up untracked root scratch files; rewrite `tracksolid_db_connection.md` for the SSH-tunnel workflow once the DB port closes. |
| B3 | Longer-term: consider vendoring Chart.js/MapLibre into `src/` (removes the CDN trust surface entirely and lets the CSP drop jsdelivr/unpkg). |
## Verification
- Extracted inline script passes `node --check`.
- Caddyfile passes `caddy validate` (run inside the deployed fleetops image).
- Manual: staging deploy → open all four tabs, confirm charts/map/popups render,
check DevTools console for CSP violations before promoting.

View file

@ -1,32 +0,0 @@
# FleetOps — Work Done (2026-07-02)
Execution log for `260702_fix_plan.md` Phase A. **Local changes only — nothing
committed, pushed, or deployed.**
## Changes
| Finding | Files | What changed |
|---|---|---|
| FO-SEC-01 | `src/index.html` | `escapeHtml` moved to HELPERS with a new `esc()` shorthand; all previously-unescaped API strings now escape: `renderVehicles` (vehicle_number, cost_centre, assigned_city), `renderDrivers` (driver_name, assigned_city), `renderFuel` banner notes, `renderFuelVehicles`, `renderFuelDepartments`, `renderFuelRecent` (plate, department, driver, fuel_type), and the three catch-block error banners (`e.message`). WhatsApp-sourced Fuel Log strings can no longer inject markup. |
| FO-SEC-02 | `src/index.html` | SRI `integrity` + `crossorigin="anonymous"` on Chart.js 4.4.1, maplibre-gl 4.7.1 JS and CSS (sha384 computed from the exact CDN payloads; recompute one-liner documented inline for future bumps). |
| FO-SEC-03 | `Caddyfile` | Security header block: `nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, CSP (self + pinned CDNs + CARTO basemap + the two fleet APIs; `worker-src blob:` for MapLibre; `frame-ancestors 'none'`), `-Server`. `script-src` retains `'unsafe-inline'` because the app is a single inline script block. |
| FO-BUG-01 | `src/index.html` | `loadLive()` now throws on non-OK responses instead of parsing error bodies. |
| FO-BUG-02 | `src/index.html` | The 15 s live-position poll skips while `document.hidden` or when the active tab isn't Tickets; switching back to Tickets or re-focusing the page triggers an immediate refresh. Cuts thousands of pointless API calls/day per idle viewer. |
| FO-OPS-01 | `src/index.html` | Missing-`API_BASE` fallback now targets the prod bridge (`fleetapi.rahamafresh.com`) per the documented design (was the staging bridge). |
## Verification
- Inline app script extracted and passed `node --check` (no syntax errors).
- Modified Caddyfile passed `caddy validate` inside the deployed fleetops image on
twala ("Valid configuration").
- 37 `esc(`/`escapeHtml(` call sites after the change (was 21, tickets-only).
## NOT done — operational
1. Deploy flows through the Forgejo → Coolify webhook on push (staging first).
Before **prod** promotion, redeploy the prod `dashboard_api` bridge — it lacks
the INC/CRQ/fuel-fills routes this SPA version calls.
2. After the staging deploy, click through all four tabs with DevTools open to
confirm zero CSP violations before promoting.
3. Root scratch files (`marker-preview.html`, `tracksolid_db_connection.md`,
`webook_instructions.txt`) left untouched — decide keep/commit/delete;
`tracksolid_db_connection.md` should be rewritten for the SSH-tunnel workflow
once the public DB port closes.

View file

@ -1,261 +0,0 @@
<!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

@ -1,240 +0,0 @@
# 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

@ -1,302 +0,0 @@
<!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

@ -1,184 +0,0 @@
# 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