158 lines
6.8 KiB
Markdown
158 lines
6.8 KiB
Markdown
|
|
# Handoff — `dashboard_api` endpoint contract: INC operations dashboard
|
|||
|
|
|
|||
|
|
For the **dashboard_api** repo (tracksolid stack). This endpoint is a thin wrapper
|
|||
|
|
over `reporting.fn_inc_dashboard(...)` in `tracksolid_db` (built by `fleettickets`,
|
|||
|
|
migration 09). It powers the FleetOps **live INC map** (open tickets + windowed
|
|||
|
|
closed overlay + metric cards). Vehicle positions/routes come from **FleetNow** and
|
|||
|
|
are overlaid by the SPA — **not** part of this endpoint.
|
|||
|
|
|
|||
|
|
## Endpoint
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /webhook/inc-dashboard
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Mirror the existing `GET /webhook/tickets` (→ `reporting.fn_tickets_for_map`) for
|
|||
|
|
auth, connection (role `dashboard_ro`), and JSON passthrough conventions.
|
|||
|
|
|
|||
|
|
### Query parameters
|
|||
|
|
|
|||
|
|
| Param | Type | Required | Default | Maps to | Notes |
|
|||
|
|
|---|---|---|---|---|---|
|
|||
|
|
| `cluster` | string | no | — (all) | `p_cluster` | Exact match on `tickets.inc.cluster` (UPPERCASE, e.g. `MUIGAI INN`). Empty = no filter. |
|
|||
|
|
| `status` | string | no | — (all) | `p_status` | Exact `normalized_status` (UPPERCASE, e.g. `ACCEPTED`, `CLOSED COMPLETE`). |
|
|||
|
|
| `window` | enum | no | `today` | `p_window` | One of `today` \| `week` \| `month` \| `custom`. Calendar **EAT** (week = ISO Mon-start). |
|
|||
|
|
| `from` | ISO-8601 timestamp | only if `window=custom` | — | `p_from` | Inclusive start. Send with offset/Z (absolute time). |
|
|||
|
|
| `to` | ISO-8601 timestamp | only if `window=custom` | — | `p_to` | Exclusive end. |
|
|||
|
|
|
|||
|
|
**Resolution rules (match the SQL):**
|
|||
|
|
- If **either** `from` or `to` is present, the function treats it as a **custom**
|
|||
|
|
window (the `window` value is overridden to `custom`); a missing side becomes
|
|||
|
|
±infinity. So for a bounded custom range, **send both** `from` and `to`.
|
|||
|
|
- Otherwise the preset (`today`/`week`/`month`) is resolved to EAT calendar bounds
|
|||
|
|
**inside the function** — the API does not compute dates.
|
|||
|
|
|
|||
|
|
**Validation (API side):**
|
|||
|
|
- `window` ∉ {today,week,month,custom} → `400`.
|
|||
|
|
- `window=custom` with neither `from` nor `to` → `400` (would mean "all time").
|
|||
|
|
- `from`/`to` unpar, or `from >= to` → `400`.
|
|||
|
|
- `cluster`/`status` are passed through verbatim (unknown values simply return empty
|
|||
|
|
sets — not an error).
|
|||
|
|
|
|||
|
|
### Implementation (one call, passthrough)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
SELECT reporting.fn_inc_dashboard(
|
|||
|
|
p_cluster := $1, -- text | null
|
|||
|
|
p_status := $2, -- text | null
|
|||
|
|
p_window := $3, -- text (default 'today')
|
|||
|
|
p_from := $4, -- timestamptz | null
|
|||
|
|
p_to := $5 -- timestamptz | null
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
Return the resulting `jsonb` **as the HTTP body unchanged** (`Content-Type:
|
|||
|
|
application/json`). No reshaping needed.
|
|||
|
|
|
|||
|
|
## Response (200) — shape
|
|||
|
|
|
|||
|
|
```jsonc
|
|||
|
|
{
|
|||
|
|
"window": { "from": "2026-06-16T00:00:00+03:00", "to": "...", "preset": "today" },
|
|||
|
|
|
|||
|
|
"open": { // ALL currently-open tickets (live; NOT time-filtered)
|
|||
|
|
"type": "FeatureCollection",
|
|||
|
|
"features": [
|
|||
|
|
{ "type": "Feature",
|
|||
|
|
"geometry": { "type": "Point", "coordinates": [lng, lat] },
|
|||
|
|
"properties": {
|
|||
|
|
"ticket_id": "WOT…", "normalized_status": "ACCEPTED",
|
|||
|
|
"cluster": "MUIGAI INN", "region": "nairobi", "location_name": "…",
|
|||
|
|
"assigned_team": "…", "owner": "…", "geo_source": "location|cluster|feed",
|
|||
|
|
"sla_state": "breached|at_risk|ok|unknown", "hours_open": 107.7
|
|||
|
|
} }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
"closed": { // closed_at within the selected window
|
|||
|
|
"type": "FeatureCollection",
|
|||
|
|
"features": [
|
|||
|
|
{ "type": "Feature",
|
|||
|
|
"geometry": { "type": "Point", "coordinates": [lng, lat] },
|
|||
|
|
"properties": {
|
|||
|
|
"ticket_id": "WOT…", "normalized_status": "CLOSED COMPLETE",
|
|||
|
|
"cluster": "…", "region": "…", "location_name": "…",
|
|||
|
|
"assigned_team": "…", "owner": "…", "geo_source": "…",
|
|||
|
|
"closed_at": "2026-06-15T13:04:52+00:00", "mttr": 1467, "sla_status": "Compliant|Breached"
|
|||
|
|
} }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
"metrics": {
|
|||
|
|
"open_now": 30,
|
|||
|
|
"closed_in_window": 2605,
|
|||
|
|
"sla": {
|
|||
|
|
"open": { "breached": 30, "at_risk": 0, "ok": 0, "unknown": 0 },
|
|||
|
|
"closed": { "compliant": 1718, "breached": 887 }
|
|||
|
|
},
|
|||
|
|
"by_status": { "ACCEPTED": 20, "PENDING DISPATCH": 7, "CLOSED COMPLETE": 2400, … },
|
|||
|
|
"by_cluster": { "MUIGAI INN": 82, "JUJA": …, "(none)": 1, … },
|
|||
|
|
"closure_rate": { "per_day_avg": 86.83, "series": [ { "day": "2026-06-15", "count": 13 }, … ] },
|
|||
|
|
"avg_mttr_min": 1467.1
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
"freshness": { "inc": { "export_type": "full", "exported_at": "…", "records_ingested": 21301, "ingested_at": "…" } }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Field semantics the SPA must know
|
|||
|
|
|
|||
|
|
- **`open` is always the full open set** (matching cluster/status); the time window
|
|||
|
|
does **not** filter it. **`closed`** is only the overlay for the window.
|
|||
|
|
- **`mttr` is in MINUTES** (null until closed). `hours_open` is hours.
|
|||
|
|
- **`sla_state`** (open) is **derived** (`now − created_at_service`, falling back to
|
|||
|
|
`first_seen_at`); `unknown` = no creation clock. **`sla_status`** (closed) is the
|
|||
|
|
source field (`Compliant`/`Breached`).
|
|||
|
|
- **Map vs metrics gap:** `features` include only geocoded rows (`geom` present);
|
|||
|
|
metric **counts include all matching rows** (a few tickets have no `geom`, e.g.
|
|||
|
|
null cluster). So `open.features.length` may be `< metrics.open_now`.
|
|||
|
|
- **`geo_source`** indicates precision: `location` (street-level) > `cluster`
|
|||
|
|
(centroid; many tickets share a point) > `feed` > none.
|
|||
|
|
- **Coordinates** are GeoJSON `[lng, lat]` (x, y) order.
|
|||
|
|
- **`window.from/to`** are absolute timestamps; presets are EAT calendar ranges.
|
|||
|
|
|
|||
|
|
## Examples
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# today (default)
|
|||
|
|
curl '.../webhook/inc-dashboard'
|
|||
|
|
|
|||
|
|
# this month, one cluster
|
|||
|
|
curl '.../webhook/inc-dashboard?window=month&cluster=MUIGAI%20INN'
|
|||
|
|
|
|||
|
|
# open ACCEPTED tickets (today window irrelevant to the open layer)
|
|||
|
|
curl '.../webhook/inc-dashboard?status=ACCEPTED'
|
|||
|
|
|
|||
|
|
# custom range (send BOTH from & to; absolute times)
|
|||
|
|
curl '.../webhook/inc-dashboard?from=2026-06-09T00:00:00%2B03:00&to=2026-06-16T00:00:00%2B03:00'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Operational notes
|
|||
|
|
|
|||
|
|
- **Caching:** the open layer + freshness change at most **hourly** (the ingest
|
|||
|
|
cadence); closed/metrics change only when filters change. A short cache
|
|||
|
|
(≈60 s) keyed on the full query string is safe; or `Cache-Control: no-store` if
|
|||
|
|
you prefer always-fresh — the function is cheap (indexed).
|
|||
|
|
- **Performance:** indexed (`ix_inc_closed_at`, `ix_inc_cluster_col`,
|
|||
|
|
`ix_inc_norm_status_col`, `ix_inc_geom`); month-window payloads are a few thousand
|
|||
|
|
closed features — fine for one request. If payloads get large, consider a
|
|||
|
|
`metrics-only` flag later (not in scope now).
|
|||
|
|
- **Auth:** same as `/webhook/tickets`. The function is granted to `dashboard_ro`.
|
|||
|
|
- **Timezone:** all preset math is EAT inside the DB; the API passes `from`/`to`
|
|||
|
|
through as absolute `timestamptz`.
|
|||
|
|
|
|||
|
|
## Out of scope (other work)
|
|||
|
|
|
|||
|
|
- FleetNow vehicle positions/routes overlay — SPA + FleetNow.
|
|||
|
|
- Open-backlog-over-time / observed transitions — needs the `fleettickets` history
|
|||
|
|
capture (not built yet); this endpoint reports current state + closure events.
|