Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fb39aa992 | ||
|
|
e58195073d | ||
|
|
6bddccb91a | ||
|
|
82a6d11d95 | ||
|
|
b2c4cbe378 | ||
|
|
a364418df1 | ||
|
|
2d7cc98d4b | ||
|
|
8a0e1304ab | ||
|
|
b653327b0f | ||
|
|
51a84b66c6 | ||
|
|
a32735bca3 | ||
|
|
62479c0a72 | ||
|
|
737f8c3d31 | ||
|
|
930a646116 | ||
|
|
1ec25b70be | ||
|
|
0ed9b6a252 | ||
|
|
6504328e58 | ||
|
|
e32ec92cbf | ||
|
|
f27afcfa9e | ||
|
|
6bfb72751f | ||
|
|
59620722cd | ||
|
|
d907ea9425 | ||
|
|
2611212fcd | ||
|
|
c2908cc17d | ||
|
|
71f40e8c62 |
5 changed files with 1957 additions and 2 deletions
261
docs/tickets-inc-implementation-guide.html
Normal file
261
docs/tickets-inc-implementation-guide.html
Normal 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 & data flow</h2>
|
||||
<pre><code>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)</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 & 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 - <<'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; 01–08 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 - <<'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 374–390).</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 182–252) 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><main></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>< 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>
|
||||
240
docs/tickets-inc-implementation-guide.md
Normal file
240
docs/tickets-inc-implementation-guide.md
Normal 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 (01–08) are
|
||||
**skipped**. Expected new: `09_inc_dashboard_fn.sql` (and `10_inc_history_capture.sql`
|
||||
if not yet present). All migrations are `CREATE OR REPLACE` / `IF NOT EXISTS`.
|
||||
- Sanity check the function returns valid JSON:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
import os, json, psycopg2
|
||||
c = psycopg2.connect(os.environ["DATABASE_URL"]); cur = c.cursor()
|
||||
cur.execute("SELECT reporting.fn_inc_dashboard()")
|
||||
d = cur.fetchone()[0]
|
||||
print("keys:", list(d.keys()))
|
||||
print("window:", d["window"])
|
||||
print("open feats:", len(d["open"]["features"]), " closed feats:", len(d["closed"]["features"]))
|
||||
print("metrics.open_now:", d["metrics"]["open_now"], " closed_in_window:", d["metrics"]["closed_in_window"])
|
||||
PY
|
||||
```
|
||||
|
||||
### Step A3 — Add the `/webhook/inc-dashboard` handler
|
||||
|
||||
File: `tracksolid_timescale_grafana_prod/dashboard_api_rev.py`. Mirror the existing
|
||||
`tickets()` handler (`:275`). Reuse `get_conn`, `_clean`, `log`.
|
||||
|
||||
1. Add `Query` to the FastAPI import (line ~46):
|
||||
```python
|
||||
from fastapi import FastAPI, Request, Query
|
||||
```
|
||||
2. Add the handler (place near the `tickets()` endpoint):
|
||||
```python
|
||||
_INC_WINDOWS = {"today", "week", "month", "custom"}
|
||||
|
||||
@app.get("/webhook/inc-dashboard")
|
||||
def inc_dashboard(
|
||||
cluster: str | None = None,
|
||||
status: str | None = None,
|
||||
window: str = "today",
|
||||
from_: str | None = Query(None, alias="from"),
|
||||
to: str | None = None,
|
||||
):
|
||||
# --- validation (contract) ---
|
||||
if window not in _INC_WINDOWS:
|
||||
return JSONResponse({"error": {"type": "bad_request",
|
||||
"message": "window must be one of today|week|month|custom"}}, status_code=400)
|
||||
f, t = _clean(from_), _clean(to)
|
||||
if window == "custom" and not f and not t:
|
||||
return JSONResponse({"error": {"type": "bad_request",
|
||||
"message": "custom window requires from and/or to"}}, status_code=400)
|
||||
def _parse(v):
|
||||
if not v: return None
|
||||
try: return datetime.fromisoformat(v)
|
||||
except ValueError: return False
|
||||
pf, pt = _parse(f), _parse(t)
|
||||
if pf is False or pt is False:
|
||||
return JSONResponse({"error": {"type": "bad_request",
|
||||
"message": "from/to must be ISO-8601 with offset"}}, status_code=400)
|
||||
if pf and pt and pf >= pt:
|
||||
return JSONResponse({"error": {"type": "bad_request",
|
||||
"message": "from must be < to"}}, status_code=400)
|
||||
# --- one passthrough call ---
|
||||
try:
|
||||
with get_conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT reporting.fn_inc_dashboard(%s, %s, %s, %s, %s)",
|
||||
(_clean(cluster), _clean(status), window, f, t),
|
||||
)
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(payload) # JSON body unchanged
|
||||
except Exception:
|
||||
log.exception("inc-dashboard failed")
|
||||
return JSONResponse({"error": {"type": "unknown",
|
||||
"message": "INC dashboard is unavailable. Try again in a few seconds."}})
|
||||
```
|
||||
- `datetime` is already imported (`from datetime import …`).
|
||||
- Leave the legacy `/webhook/tickets` handler untouched (CRQ / fallback).
|
||||
|
||||
### Step A4 — Deploy to staging
|
||||
|
||||
```bash
|
||||
cd ~/Downloads/projects/tracksolid_timescale_grafana_prod
|
||||
scp dashboard_api_rev.py kianiadee@twala.rahamafresh.com:~/dashboard_api_staging_rev.py
|
||||
scp deploy_dashboard_api_staging.sh kianiadee@twala.rahamafresh.com:~/
|
||||
ssh kianiadee@twala.rahamafresh.com 'bash ~/deploy_dashboard_api_staging.sh'
|
||||
```
|
||||
The script stages the file into the mount and **recreates** the `dashboard_api_staging`
|
||||
container (CORS already allows `https://fleetops.fivetitude.com`).
|
||||
|
||||
### Step A5 — Verify the endpoint
|
||||
|
||||
```bash
|
||||
B=https://fleetapi.fivetitude.com
|
||||
curl -s "$B/webhook/inc-dashboard" | head -c 400; echo # 200, today
|
||||
curl -s "$B/webhook/inc-dashboard?window=month" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["metrics"])'
|
||||
curl -s "$B/webhook/inc-dashboard?status=ACCEPTED" | python3 -c 'import sys,json;d=json.load(sys.stdin);print("open",d["metrics"]["open_now"])'
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "$B/webhook/inc-dashboard?window=bogus" # 400
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "$B/webhook/inc-dashboard?window=custom" # 400
|
||||
```
|
||||
Cross-check `metrics.open_now` against `SELECT count(*) FROM tickets.inc WHERE is_actionable`.
|
||||
|
||||
---
|
||||
|
||||
## Phase B — SPA overhaul (`15_fleetops/src/index.html`, single file)
|
||||
|
||||
### Step B1 — Erase the existing INC/CRQ view
|
||||
- Delete the full-bleed `#view-tickets` map section (markup, ~lines 374–390).
|
||||
- Remove `loadTickets()` (calls `/webhook/tickets`), the **CRQ** circle layer, the
|
||||
combined INC/CRQ summary handling, `TICKET_COLORS`, `ticketStats.crq`, and the old
|
||||
`showTicketPopup()` (rebuilt for INC below).
|
||||
- Keep the map/marker/popup CSS (~lines 182–252) and the warm-dark palette.
|
||||
|
||||
### Step B2 — Reuse (do NOT reinvent)
|
||||
Vehicle overlay machinery stays: `loadLive()` (`/webhook/live-positions`, 15s poll),
|
||||
`upsertVeh()`, `showVehPopup()`, `vehState()`, `ccColor()`, `pastel()`, `plateTail()`,
|
||||
`BASEMAP`, `COST_CENTRE_COLORS`, `CC_PALETTE`, `escapeHtml`, `updateVehScale()`, the
|
||||
layers-panel builder. Reuse the Logistics/Fuel filterbar + `.card`/`.span*` grid +
|
||||
`num()`/`intg()` + custom-range show/hide (`:467`).
|
||||
|
||||
### Step B3 — New `#view-tickets` markup (dashboard cards + map)
|
||||
- **Filterbar:** `Cluster` select · `Status` select · `Window` select
|
||||
(Today / This week / This month / Custom) + custom start/end dates · `Apply` · `↻`.
|
||||
- **`<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`.
|
||||
302
docs/tickets-inc-overhaul-plan.html
Normal file
302
docs/tickets-inc-overhaul-plan.html
Normal 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>! <cmd></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&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><main></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>< 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>
|
||||
184
docs/tickets-inc-overhaul-plan.md
Normal file
184
docs/tickets-inc-overhaul-plan.md
Normal 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`.
|
||||
972
src/index.html
972
src/index.html
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue