- docs/phase-1-ingestion.md — Phase 1 PRD (INC hourly CSV ingestion; deployed) - docs/phase-2-dashboard.md — Phase 2 PRD (inc_dashboard read-API for FleetOps map) - docs/implementation.md — as-built record (pipeline, migrations 01-08, deploy, DQ) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.7 KiB
PRD (Phase 2) — INC operations dashboard: read-API layer
Phase 1 (hourly INC CSV ingestion →
tickets.inc, geocoding, typed generated columns,inc_open_slaview) is complete and deployed (migrations 01–08, Coolify hourly15 7-19 * * *EAT). Seedocs/phase-1-ingestion.md/docs/implementation.md. This document is Phase 2.
Context
FleetOps needs a live INC operations map (modelled on FleetNow):
- A map showing all currently-open INC tickets alongside live vehicle positions from FleetNow.
- A bottom timeline bar that overlays closed tickets (alongside FleetNow vehicle routes) for a selected period.
- Bottom filters:
cluster, ticketstatus, and time = today / this week / this month / custom date. - Top metric cards that react to the selected filters — ticket metrics (not vehicle metrics).
Scope of THIS repo (confirmed): the data / read-API layer only. fleettickets
exposes parameterized SQL in tracksolid_db that dashboard_api serves to the
FleetOps SPA. The map UI, timeline bar, filter controls, metric cards, and the
FleetNow vehicle positions/routes are other repos/systems. There is no
vehicle id in the INC feed, so we serve tickets only; the SPA overlays FleetNow
vehicles/routes.
Confirmed behaviour
- Open layer (live): all
is_actionable = trueINC tickets matching the cluster/status filter — not time-filtered (open = needs action now). - Closed overlay (windowed): closed tickets whose
closed_atfalls in the selected window, matching cluster/status. - Metric cards (windowed): computed for the current selection.
- Filters combine with AND, each optional. Windows are calendar EAT
(today / ISO-week / month) or an explicit custom
[from, to). - Delivery: one parameterized function returning a single JSON payload
{ open: GeoJSON, closed: GeoJSON, metrics: {…}, window, freshness }, mirroring the existingreporting.fn_tickets_for_mapstyle.
Deliverable — migrations/09_inc_dashboard_fn.sql
A new read function (and supporting index if needed); additive, idempotent
(CREATE OR REPLACE), no change to existing objects.
reporting.fn_inc_dashboard(...)
reporting.fn_inc_dashboard(
p_cluster text DEFAULT NULL, -- exact cluster (matches tickets.inc.cluster)
p_status text DEFAULT NULL, -- normalized_status
p_window text DEFAULT 'today', -- 'today' | 'week' | 'month' | 'custom'
p_from timestamptz DEFAULT NULL, -- custom window start (inclusive)
p_to timestamptz DEFAULT NULL -- custom window end (exclusive)
) RETURNS jsonb
- Window resolution: if
p_from/p_togiven → use them (custom). Else compute EAT calendar bounds fromp_window:today=[date_trunc('day', now_eat), +1 day),week=date_trunc('week', …),month=date_trunc('month', …)— converted back totimestamptzvia… AT TIME ZONE 'Africa/Nairobi'. - Returned JSON:
{ "window": { "from": "...", "to": "...", "preset": "today" }, "open": { "type":"FeatureCollection", "features":[ … ] }, // all open, filtered by cluster/status "closed": { "type":"FeatureCollection", "features":[ … ] }, // closed_at in window, filtered "metrics": { "open_now": int, "closed_in_window": int, "sla": { "open": { "breached": int, "at_risk": int, "ok": int, "unknown": int }, "closed": { "compliant": int, "breached": int } }, "by_status": { "<status>": int, … }, "by_cluster": { "<cluster>": int, … }, "closure_rate": { "per_day_avg": num, "series": [ { "day":"YYYY-MM-DD", "count":int }, … ] }, "avg_mttr_min": num }, "freshness": { … } // from tickets.import_meta } - Feature properties (both layers):
ticket_id, normalized_status, cluster, region, location_name, assigned_team, owner, geo_source. Open addssla_state, hours_open; closed addsclosed_at, mttr, sla_status. Geometry fromgeom(ST_AsGeoJSON). Onlygeom IS NOT NULLrows become features;metricscount the full filtered set (note the small geocoding gap).
Reuse (don't reinvent)
tickets.inc_open_sla(migration 08) —sla_state/hours_openfor the open layer + open-SLA metrics.- Typed generated columns (migrations 03–07):
cluster,normalized_status,closed_at,mttr(minutes),assigned_team,geom,geo_source. reporting.fn_tickets_for_map(migrations 01–02) — GeoJSONjsonb_build_object/ST_AsGeoJSON+summary.freshnesspatterns.- Derived SLA logic —
now() − COALESCE(created_at_service, first_seen_at)vs 48h/36h.
Indexes
In place: ix_inc_closed_at, ix_inc_cluster_col, ix_inc_norm_status_col,
ix_inc_actionable_col, ix_inc_geom, ix_inc_geog. Add composite
(closed_at, cluster) only if EXPLAIN shows it's needed.
Grants
GRANT EXECUTE ON FUNCTION reporting.fn_inc_dashboard(...) TO dashboard_ro (guarded).
Dependencies (other repos)
dashboard_api— endpoint e.g.GET /webhook/inc-dashboard?cluster=&status=&window=&from=&to=callingfn_inc_dashboard. (Contract here; impl there.)- FleetOps SPA (
fleetops) — map, timeline bar, filter UI, metric cards; overlays FleetNow vehicles/routes. - FleetNow — live vehicle positions + historical routes.
Data-quality caveats (affect metrics, not delivery)
- Source
sla_statusonly meaningful for closed; open SLA is derived. created_at_servicenull on ~30% → some open are SLAunknown(fallback flagged).mttris minutes, null until closed; closure/MTTR metrics filter accordingly.- Content lag ~2 days → recent days under-count.
- A few tickets lack
geom→ counted in metrics, absent from map features.
Verification
SELECT reporting.fn_inc_dashboard();→ valid JSON (open/closed FCs, metrics, window=today, freshness).- Filters:
p_cluster,p_status,p_window := 'month', and a customp_from/p_to— counts match ad-hocSELECTs ontickets.inc/tickets.inc_open_sla. - Window math: today/week/month are correct EAT calendar ranges.
- SLA metrics match the
inc_open_sladistribution / sourcesla_statusin window. EXPLAIN ANALYZEon the windowed closed query usesix_inc_closed_at.- Apply via
run_migrations.py; ledgered intickets.schema_migrations.
Out of scope (future)
- Open-backlog-over-time / observed open→closed transitions need the append-only
history capture (
tickets.closure_events+ daily snapshot) — separate plan. - Dispatch surface (nearest-vehicle off
geog) — after analytics.