fleetops/docs/tickets-inc-overhaul-plan.html

303 lines
17 KiB
HTML
Raw Permalink Normal View History

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