Compare commits
26 commits
main
...
fix/spa-xs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0022fbeaf | ||
|
|
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 |
9 changed files with 2129 additions and 11 deletions
13
Caddyfile
13
Caddyfile
|
|
@ -8,6 +8,19 @@
|
||||||
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
|
||||||
|
|
|
||||||
67
docs/260702_audit_report.md
Normal file
67
docs/260702_audit_report.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 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.
|
||||||
27
docs/260702_fix_plan.md
Normal file
27
docs/260702_fix_plan.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# 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.
|
||||||
32
docs/260702_work_done.md
Normal file
32
docs/260702_work_done.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# 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.
|
||||||
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`.
|
||||||
1014
src/index.html
1014
src/index.html
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue