diff --git a/.env.example b/.env.example index 871d308..e2c738b 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ TRACKSOLID_APP_SECRET= TRACKSOLID_USER_ID= TRACKSOLID_PWD_MD5= TRACKSOLID_TARGET_ACCOUNT= +# Comma-separated list of subaccounts; takes precedence over TARGET_ACCOUNT. +# Example: TRACKSOLID_TARGETS=Fireside Communications,Customer-A,Customer-B +TRACKSOLID_TARGETS= TRACKSOLID_TOKEN_TTL_SEC=7200 TRACKSOLID_POLL_INTERVAL_SEC=60 TRACKSOLID_STALE_POLL_INTERVAL_SEC=600 diff --git a/app/config.py b/app/config.py index fab4ff8..3170708 100644 --- a/app/config.py +++ b/app/config.py @@ -21,6 +21,7 @@ class Settings(BaseSettings): tracksolid_user_id: str = Field(default="", alias="TRACKSOLID_USER_ID") tracksolid_pwd_md5: str = Field(default="", alias="TRACKSOLID_PWD_MD5") tracksolid_target_account: str = Field(default="", alias="TRACKSOLID_TARGET_ACCOUNT") + tracksolid_targets: str = Field(default="", alias="TRACKSOLID_TARGETS") # comma-separated tracksolid_token_ttl_sec: int = Field(default=7200, alias="TRACKSOLID_TOKEN_TTL_SEC") tracksolid_poll_interval_sec: int = Field(default=60, alias="TRACKSOLID_POLL_INTERVAL_SEC") tracksolid_stale_poll_interval_sec: int = Field( diff --git a/app/entrypoints/cron.py b/app/entrypoints/cron.py index 1ce8c22..c2ebb77 100644 --- a/app/entrypoints/cron.py +++ b/app/entrypoints/cron.py @@ -49,7 +49,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: async def _run_stale() -> None: await poller.poll_stale_imeis(client, settings) - if settings.tracksolid_target_account and settings.tracksolid_app_key: + has_target = bool(settings.tracksolid_target_account or settings.tracksolid_targets) + if has_target and settings.tracksolid_app_key: scheduler.add_job( _run_list, trigger=IntervalTrigger(seconds=settings.tracksolid_poll_interval_sec), diff --git a/app/parsers/jimi.py b/app/parsers/jimi.py index dc8c46d..0777895 100644 --- a/app/parsers/jimi.py +++ b/app/parsers/jimi.py @@ -61,6 +61,11 @@ def _fix_payload( satellites: int | None = None, acc: Any = None, source: str, + mc_type: str | None = None, + device_name: str | None = None, + current_mileage_km: float | None = None, + gps_signal: int | None = None, + pos_type: str | None = None, ) -> dict[str, Any]: return { "lat": lat, @@ -71,6 +76,11 @@ def _fix_payload( "satellites": satellites, "acc": acc, "source": source, + "mc_type": mc_type, + "device_name": device_name, + "current_mileage_km": current_mileage_km, + "gps_signal": gps_signal, + "pos_type": pos_type, } @@ -207,13 +217,29 @@ def _parse_poll_list(payload: dict[str, Any], account_id: str | None) -> list[Pa try: model = JimiPollFix.model_validate(item) except Exception: - # malformed item — don't fail the whole batch; logged at parser continue if model.gps_time is None: continue if not _is_valid_fix(model.lat, model.lng): continue assert model.lat is not None and model.lng is not None + + # Tracksolid sends these as strings in the wire payload; coerce now. + def _f(v: Any) -> float | None: + if v in (None, ""): + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + def _i(v: Any) -> int | None: + f = _f(v) + return int(f) if f is not None else None + + raw_signal = item.get("gpsSignal") + raw_mileage = item.get("currentMileage") + out.append( ParsedEvent( kind="position_fix", @@ -229,6 +255,11 @@ def _parse_poll_list(payload: dict[str, Any], account_id: str | None) -> list[Pa satellites=model.satellites, acc=model.acc, source="tracksolid_poll", + mc_type=model.mc_type, + device_name=model.device_name, + current_mileage_km=_f(raw_mileage), + gps_signal=_i(raw_signal), + pos_type=str(model.pos_type) if model.pos_type is not None else None, ), ) ) diff --git a/app/projectors/live_positions.py b/app/projectors/live_positions.py index 339e9d8..a21438a 100644 --- a/app/projectors/live_positions.py +++ b/app/projectors/live_positions.py @@ -102,34 +102,50 @@ async def _project_one( geom_wkt = f"POINT({lng} {lat})" + acc_int = payload.get("acc") if isinstance(payload.get("acc"), int) else None + await cur.execute( """ INSERT INTO state.live_positions ( imei, vehicle_id, occurred_at, geom, speed_kmh, direction_deg, - acc_state, source, parser_version, updated_at + acc_state, source, parser_version, updated_at, + mc_type, current_mileage_km, gps_signal, satellites, device_name, pos_type ) VALUES ( %s, %s, %s, ST_SetSRID(ST_GeomFromText(%s), 4326), - %s, %s, %s, %s, %s, now() + %s, %s, %s, %s, %s, now(), + %s, %s, %s, %s, %s, %s ) ON CONFLICT (imei) DO UPDATE - SET vehicle_id = EXCLUDED.vehicle_id, - occurred_at = EXCLUDED.occurred_at, - geom = EXCLUDED.geom, - speed_kmh = EXCLUDED.speed_kmh, - direction_deg = EXCLUDED.direction_deg, - acc_state = EXCLUDED.acc_state, - source = EXCLUDED.source, - parser_version = EXCLUDED.parser_version, - updated_at = now() + SET vehicle_id = EXCLUDED.vehicle_id, + occurred_at = EXCLUDED.occurred_at, + geom = EXCLUDED.geom, + speed_kmh = EXCLUDED.speed_kmh, + direction_deg = EXCLUDED.direction_deg, + acc_state = EXCLUDED.acc_state, + source = EXCLUDED.source, + parser_version = EXCLUDED.parser_version, + updated_at = now(), + mc_type = EXCLUDED.mc_type, + current_mileage_km = EXCLUDED.current_mileage_km, + gps_signal = EXCLUDED.gps_signal, + satellites = EXCLUDED.satellites, + device_name = EXCLUDED.device_name, + pos_type = EXCLUDED.pos_type WHERE EXCLUDED.occurred_at > state.live_positions.occurred_at """, ( imei, vehicle_id, occurred_at, geom_wkt, payload.get("speed_kmh"), payload.get("direction_deg"), - payload.get("acc") if isinstance(payload.get("acc"), int) else None, + acc_int, payload.get("source") or "unknown", PARSER_VERSION, + payload.get("mc_type"), + payload.get("current_mileage_km"), + payload.get("gps_signal"), + payload.get("satellites"), + payload.get("device_name"), + payload.get("pos_type"), ), ) @@ -137,21 +153,27 @@ async def _project_one( """ INSERT INTO state.position_history ( vehicle_id, imei, occurred_at, geom, speed_kmh, direction_deg, - acc_state, altitude_m, satellites, source, parser_version + acc_state, altitude_m, satellites, source, parser_version, + mc_type, current_mileage_km, gps_signal, pos_type ) VALUES ( %s, %s, %s, ST_SetSRID(ST_GeomFromText(%s), 4326), - %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s ) """, ( vehicle_id, imei, occurred_at, geom_wkt, payload.get("speed_kmh"), payload.get("direction_deg"), - payload.get("acc") if isinstance(payload.get("acc"), int) else None, + acc_int, payload.get("altitude_m"), payload.get("satellites"), payload.get("source") or "unknown", PARSER_VERSION, + payload.get("mc_type"), + payload.get("current_mileage_km"), + payload.get("gps_signal"), + payload.get("pos_type"), ), ) return True diff --git a/app/workers/poller.py b/app/workers/poller.py index ec50ea4..86afc23 100644 --- a/app/workers/poller.py +++ b/app/workers/poller.py @@ -40,23 +40,37 @@ async def _insert_raw( return int(row[0]) +def _targets(settings: Settings) -> list[str]: + """Return the configured list of Tracksolid target accounts. + + TRACKSOLID_TARGETS (comma-separated) takes precedence; falls back to the + single TRACKSOLID_TARGET_ACCOUNT for backward compatibility. + """ + if settings.tracksolid_targets: + return [t.strip() for t in settings.tracksolid_targets.split(",") if t.strip()] + if settings.tracksolid_target_account: + return [settings.tracksolid_target_account] + return [] + + async def poll_live_positions(client: TracksolidClient, settings: Settings) -> None: - target = settings.tracksolid_target_account - if not target: - log.warning("poller.list_skipped_no_target") + targets = _targets(settings) + if not targets: + log.warning("poller.list_skipped_no_targets") return - try: - body = await client.location_list(target=target) - except TracksolidError: - log.exception("poller.list_api_error") - return - except Exception: - log.exception("poller.list_crashed") - return - result = body.get("result") - n = len(result) if isinstance(result, list) else 0 - eid = await _insert_raw(source="tracksolid_poll_list", account_id=target, payload=body) - log.info("poller.list_ok", event_id=eid, devices=n) + for target in targets: + try: + body = await client.location_list(target=target) + except TracksolidError: + log.exception("poller.list_api_error", target=target) + continue + except Exception: + log.exception("poller.list_crashed", target=target) + continue + result = body.get("result") + n = len(result) if isinstance(result, list) else 0 + eid = await _insert_raw(source="tracksolid_poll_list", account_id=target, payload=body) + log.info("poller.list_ok", event_id=eid, devices=n, target=target) async def _stale_imeis(settings: Settings) -> list[str]: @@ -78,9 +92,13 @@ async def _stale_imeis(settings: Settings) -> list[str]: async def poll_stale_imeis(client: TracksolidClient, settings: Settings) -> None: - target = settings.tracksolid_target_account - if not target: + """jimi.device.location.get is account-scoped via the access_token, not via + `target`. Stale-poll across all imeis regardless of which target they came + from; tag with the primary target for bookkeeping.""" + targets = _targets(settings) + if not targets: return + primary_target = targets[0] imeis = await _stale_imeis(settings) if not imeis: return @@ -96,7 +114,7 @@ async def poll_stale_imeis(client: TracksolidClient, settings: Settings) -> None log.exception("poller.get_crashed", batch_size=len(batch)) continue eid = await _insert_raw( - source="tracksolid_poll_get", account_id=target, payload=body + source="tracksolid_poll_get", account_id=primary_target, payload=body ) log.info("poller.get_ok", event_id=eid, batch_size=len(batch)) diff --git a/db/migrations/20260601000008_live_positions_richer.sql b/db/migrations/20260601000008_live_positions_richer.sql new file mode 100644 index 0000000..480d96e --- /dev/null +++ b/db/migrations/20260601000008_live_positions_richer.sql @@ -0,0 +1,31 @@ +-- migrate:up + +ALTER TABLE state.live_positions + ADD COLUMN IF NOT EXISTS mc_type text, + ADD COLUMN IF NOT EXISTS current_mileage_km numeric, + ADD COLUMN IF NOT EXISTS gps_signal int, + ADD COLUMN IF NOT EXISTS satellites int, + ADD COLUMN IF NOT EXISTS device_name text, + ADD COLUMN IF NOT EXISTS pos_type text; + +ALTER TABLE state.position_history + ADD COLUMN IF NOT EXISTS mc_type text, + ADD COLUMN IF NOT EXISTS current_mileage_km numeric, + ADD COLUMN IF NOT EXISTS gps_signal int, + ADD COLUMN IF NOT EXISTS pos_type text; + +-- migrate:down + +ALTER TABLE state.live_positions + DROP COLUMN IF EXISTS mc_type, + DROP COLUMN IF EXISTS current_mileage_km, + DROP COLUMN IF EXISTS gps_signal, + DROP COLUMN IF EXISTS satellites, + DROP COLUMN IF EXISTS device_name, + DROP COLUMN IF EXISTS pos_type; + +ALTER TABLE state.position_history + DROP COLUMN IF EXISTS mc_type, + DROP COLUMN IF EXISTS current_mileage_km, + DROP COLUMN IF EXISTS gps_signal, + DROP COLUMN IF EXISTS pos_type; diff --git a/db/migrations/20260601000009_serve_fn_live_view_v2.sql b/db/migrations/20260601000009_serve_fn_live_view_v2.sql new file mode 100644 index 0000000..45c7bfa --- /dev/null +++ b/db/migrations/20260601000009_serve_fn_live_view_v2.sql @@ -0,0 +1,172 @@ +-- migrate:up + +-- 12-colour palette (stable across runs via hashtext). +-- Used when a vehicle has a cost_centre; null cost_centre → default blue. +CREATE OR REPLACE FUNCTION serve._cost_centre_color(name text) RETURNS text +LANGUAGE sql IMMUTABLE AS $$ + SELECT CASE WHEN name IS NULL OR name = '' THEN '#3b82f6' + ELSE + (ARRAY[ + '#10b981', '#3b82f6', '#f59e0b', '#ec4899', + '#8b5cf6', '#14b8a6', '#f97316', '#06b6d4', + '#a855f7', '#84cc16', '#e11d48', '#6366f1' + ])[mod(abs(hashtext(name)), 12) + 1] + END +$$; + +DROP FUNCTION IF EXISTS serve.fn_live_view(jsonb); + +CREATE OR REPLACE FUNCTION serve.fn_live_view(filters jsonb) +RETURNS jsonb +LANGUAGE plpgsql STABLE +AS $$ +DECLARE + fresh_window interval := COALESCE((filters->>'fresh_window')::interval, interval '24 hours'); + offline_after interval := COALESCE((filters->>'offline_after')::interval, interval '5 minutes'); + move_speed_kmh numeric := COALESCE((filters->>'move_speed_kmh')::numeric, 5); + p_cost_centre text := filters->>'cost_centre'; + p_assigned_city text := filters->>'assigned_city'; + p_vehicle_numbers text[] := CASE + WHEN filters ? 'vehicle_numbers' + THEN ARRAY(SELECT jsonb_array_elements_text(filters->'vehicle_numbers')) + ELSE NULL + END; + result jsonb; +BEGIN + WITH candidates AS ( + SELECT + lp.imei, + lp.occurred_at, + lp.geom, + lp.speed_kmh, + lp.direction_deg, + lp.mc_type, + lp.current_mileage_km, + lp.gps_signal, + lp.satellites, + lp.device_name, + lp.pos_type, + d.device_type, + d.activation_at, + v.vehicle_id, + v.plate, + v.cost_centre, + v.assigned_city + FROM state.live_positions lp + JOIN domain.devices d ON d.imei = lp.imei + JOIN domain.vehicles v ON v.vehicle_id = d.vehicle_id + WHERE d.lifecycle = 'active' + AND (p_cost_centre IS NULL OR v.cost_centre = p_cost_centre) + AND (p_assigned_city IS NULL OR v.assigned_city = p_assigned_city) + AND (p_vehicle_numbers IS NULL OR v.plate = ANY (p_vehicle_numbers)) + ), + ranked AS ( + SELECT + c.*, + ROW_NUMBER() OVER ( + PARTITION BY c.vehicle_id + ORDER BY + CASE c.device_type WHEN 'tracker' THEN 0 ELSE 1 END, + CASE WHEN c.occurred_at > now() - fresh_window THEN 0 ELSE 1 END, + c.occurred_at DESC, + c.activation_at DESC NULLS LAST + ) AS rn + FROM candidates c + ), + deduped AS (SELECT * FROM ranked WHERE rn = 1), + enriched AS ( + SELECT + d.*, + CASE + WHEN d.occurred_at <= now() - offline_after THEN 'offline' + WHEN d.speed_kmh IS NOT NULL AND d.speed_kmh > move_speed_kmh THEN 'moving' + ELSE 'parked' + END AS operational_state, + serve._cost_centre_color(d.cost_centre) AS cost_centre_color, + EXTRACT(EPOCH FROM (now() - d.occurred_at))::int AS age_sec + FROM deduped d + ), + summary AS ( + SELECT jsonb_build_object( + 'total_active', count(*), + 'moving', count(*) FILTER (WHERE operational_state = 'moving'), + 'parked', count(*) FILTER (WHERE operational_state = 'parked'), + 'offline', count(*) FILTER (WHERE operational_state = 'offline'), + 'below_freshness_slo', count(*) FILTER ( + WHERE occurred_at <= now() - interval '90 seconds' + ), + 'as_of', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + ) AS s + FROM enriched + ), + features AS ( + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'type', 'Feature', + 'geometry', ST_AsGeoJSON(e.geom)::jsonb, + 'properties', jsonb_build_object( + 'vehicle_id', e.vehicle_id, + 'plate', e.plate, + 'plate_short', right(e.plate, 4), + 'imei', e.imei, + 'device_type', e.device_type, + 'device_name', e.device_name, + 'mc_type', e.mc_type, + 'pos_type', e.pos_type, + 'cost_centre', e.cost_centre, + 'cost_centre_color', e.cost_centre_color, + 'assigned_city', e.assigned_city, + 'occurred_at', to_char(e.occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), + 'age_sec', e.age_sec, + 'speed_kmh', e.speed_kmh, + 'heading_deg', e.direction_deg, + 'gps_signal', e.gps_signal, + 'satellites', e.satellites, + 'current_mileage_km', e.current_mileage_km, + 'operational_state', e.operational_state, + 'style_class', 'vehicle-' || e.operational_state, + 'marker_color', CASE WHEN e.operational_state = 'moving' + THEN e.cost_centre_color + ELSE '#9ca3af' END, + 'show_arrow', (e.operational_state = 'moving' AND e.direction_deg IS NOT NULL) + ) + ) + ), '[]'::jsonb) AS feats + FROM enriched e + ), + slo_block AS ( + SELECT COALESCE(jsonb_object_agg( + metric, + jsonb_build_object( + 'threshold', threshold, + 'current', current_value, + 'status', status + ) + ), '{}'::jsonb) AS ss + FROM slo.v_current_status + ) + SELECT jsonb_build_object( + 'summary', (SELECT s FROM summary), + 'geojson', jsonb_build_object( + 'type', 'FeatureCollection', + 'features', (SELECT feats FROM features) + ), + 'slo_status', (SELECT ss FROM slo_block) + ) + INTO result; + + RETURN result; +END; +$$; + +-- migrate:down + +DROP FUNCTION IF EXISTS serve.fn_live_view(jsonb); +-- restore v1 (keep prior live behavior) +CREATE OR REPLACE FUNCTION serve.fn_live_view(filters jsonb) +RETURNS jsonb LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN '{}'::jsonb; +END; +$$; +DROP FUNCTION IF EXISTS serve._cost_centre_color(text); diff --git a/web/fleet-core.js b/web/fleet-core.js index 7a5e013..62af671 100644 --- a/web/fleet-core.js +++ b/web/fleet-core.js @@ -1,19 +1,21 @@ // fleet-core.js // Shared client primitives for the fleet-platform dashboards. -// MapLibre is the only external dependency; everything else is vanilla ES modules. // -// All business logic lives server-side (PRD §8, arch §7). This module: -// - authClient : token cache + login + apiFetch wrapper -// - initMap : MapLibre instance + vehicle layer -// - renderView : ingest {summary, geojson, slo_status} -> DOM + map -// - initFilters : form-driven filter UI -// - clockEAT : ticks the EAT clock element +// Server-driven rendering per PRD §8: the API attaches `marker_color`, +// `show_arrow`, `style_class`, etc. The JS just paints what it's told. +// +// Markers (three MapLibre layers stacked on one source): +// 1. circle — `marker_color` (cost-centre colour when moving, grey otherwise) +// 2. arrow — white SVG icon, rotated by `heading_deg`, visible only when +// `show_arrow == true` +// 3. label — `plate_short` (last 4 chars of plate), below the circle -const API_BASE = ''; -const STORAGE_ACCESS = 'fleet.accessToken'; +const STORAGE_ACCESS = 'fleet.accessToken'; const STORAGE_REFRESH = 'fleet.refreshToken'; const STORAGE_EXPIRES = 'fleet.expiresAt'; +const VEHICLE_SOURCE = 'vehicles'; + /* ---------- authClient ---------- */ export const authClient = { @@ -24,7 +26,7 @@ export const authClient = { async login(username, password) { const body = new URLSearchParams({ username, password }); - const res = await fetch(`${API_BASE}/api/auth/token`, { + const res = await fetch('/api/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, @@ -78,29 +80,28 @@ export async function apiFetch(path, { params, ...opts } = {}) { window.location.href = '/login.html'; throw new Error('unauthorized'); } - if (!res.ok) { - throw new Error(`${res.status} ${res.statusText}`); - } + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } -/* ---------- map ---------- */ +/* ---------- map setup ---------- */ -const STYLE_CLASS_COLORS = { - 'vehicle-moving': '#10b981', - 'vehicle-parked': '#3b82f6', - 'vehicle-offline': '#9ca3af', -}; - -const VEHICLE_SOURCE = 'vehicles'; -const VEHICLE_LAYER = 'vehicles-circle'; +function _addArrowImage(map) { + if (map.hasImage('arrow-white')) return; + const svg = ` + + `; + const img = new Image(24, 24); + img.onload = () => { if (!map.hasImage('arrow-white')) map.addImage('arrow-white', img); }; + img.src = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); +} export function initMap(elementId, opts = {}) { - const center = opts.center || [36.8172, -1.2864]; // Nairobi + const center = opts.center || [36.8172, -1.2864]; // Nairobi const zoom = opts.zoom ?? 7; - const styleUrl = - opts.styleUrl || - 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'; + const styleUrl = opts.styleUrl + || 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; // eslint-disable-next-line no-undef const map = new maplibregl.Map({ @@ -112,55 +113,155 @@ export function initMap(elementId, opts = {}) { }); map.on('load', () => { + _addArrowImage(map); + map.addSource(VEHICLE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }); + map.addLayer({ - id: VEHICLE_LAYER, + id: 'vehicles-circle', type: 'circle', source: VEHICLE_SOURCE, paint: { - 'circle-radius': 7, - 'circle-color': [ - 'match', - ['get', 'style_class'], - 'vehicle-moving', STYLE_CLASS_COLORS['vehicle-moving'], - 'vehicle-parked', STYLE_CLASS_COLORS['vehicle-parked'], - 'vehicle-offline', STYLE_CLASS_COLORS['vehicle-offline'], - '#6b7280', - ], - 'circle-stroke-color': '#ffffff', - 'circle-stroke-width': 1.5, + 'circle-radius': 13, + 'circle-color': ['get', 'marker_color'], + 'circle-stroke-color': '#0b1220', + 'circle-stroke-width': 2, }, }); - }); - map.on('click', VEHICLE_LAYER, (e) => { - if (!e.features || !e.features[0]) return; - const p = e.features[0].properties; - // eslint-disable-next-line no-undef - new maplibregl.Popup() - .setLngLat(e.features[0].geometry.coordinates) - .setHTML(_popupHtml(p)) - .addTo(map); - }); + map.addLayer({ + id: 'vehicles-arrow', + type: 'symbol', + source: VEHICLE_SOURCE, + filter: ['==', ['get', 'show_arrow'], true], + layout: { + 'icon-image': 'arrow-white', + 'icon-rotate': ['coalesce', ['get', 'heading_deg'], 0], + 'icon-rotation-alignment': 'map', + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-size': 0.7, + }, + }); - map.on('mouseenter', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = 'pointer')); - map.on('mouseleave', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = '')); + map.addLayer({ + id: 'vehicles-label', + type: 'symbol', + source: VEHICLE_SOURCE, + layout: { + 'text-field': ['get', 'plate_short'], + 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], + 'text-size': 11, + 'text-offset': [0, 1.7], + 'text-anchor': 'top', + 'text-allow-overlap': true, + 'text-ignore-placement': true, + 'text-letter-spacing': 0.04, + }, + paint: { + 'text-color': '#f1f5f9', + 'text-halo-color': '#0f172a', + 'text-halo-width': 2, + }, + }); + + _wireHoverPopup(map); + }); return map; } +/* ---------- hover popup ---------- */ + +function _wireHoverPopup(map) { + // eslint-disable-next-line no-undef + const popup = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + offset: 16, + className: 'fleet-popup', + }); + + const show = (e) => { + const f = e.features && e.features[0]; + if (!f) return; + map.getCanvas().style.cursor = 'pointer'; + popup.setLngLat(f.geometry.coordinates).setHTML(_popupHtml(f.properties)).addTo(map); + }; + const hide = () => { map.getCanvas().style.cursor = ''; popup.remove(); }; + + for (const layer of ['vehicles-circle', 'vehicles-arrow', 'vehicles-label']) { + map.on('mouseenter', layer, show); + map.on('mousemove', layer, show); + map.on('mouseleave', layer, hide); + } +} + +function _esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); +} + +function _formatAge(sec) { + if (sec == null) return '—'; + const s = Math.max(0, Math.round(Number(sec))); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.round(s / 60)}m ago`; + if (s < 86400) return `${Math.round(s / 3600)}h ago`; + return `${Math.round(s / 86400)}d ago`; +} + +function _formatLocal(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString('en-GB', { + timeZone: 'Africa/Nairobi', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }).replace(',', ''); +} + function _popupHtml(props) { - const safe = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); + const state = String(props.operational_state || 'unknown').toLowerCase(); + const speed = Math.round(Number(props.speed_kmh || 0)); + const pillText = state === 'moving' + ? `MOVING · ${speed} KMH` + : (state === 'parked' ? 'PARKED' : state.toUpperCase()); + + const tagLine = [props.cost_centre, props.assigned_city] + .filter(Boolean).join(' · ').toLowerCase(); + + const headingPart = (props.heading_deg != null) + ? `heading ${Math.round(Number(props.heading_deg))}°` : null; + const sigPart = (props.gps_signal != null) ? `gps signal ${props.gps_signal}` : null; + const headingLine = [headingPart, sigPart].filter(Boolean).join(' · '); + + let mileageLine = null; + if (props.current_mileage_km != null) { + const km = Number(props.current_mileage_km); + if (!Number.isNaN(km)) { + mileageLine = `${km.toLocaleString('en-US', { maximumFractionDigits: 2 })} km on the clock`; + } + } + + const sourceLine = [props.mc_type, props.device_type].filter(Boolean).join(' · '); + const ageLine = `last fix ${_formatAge(props.age_sec)} · ${_formatLocal(props.occurred_at)}`; + return ` -
-
${safe(props.plate)} · ${safe(props.operational_state)}
-
Cost centre: ${safe(props.cost_centre || '—')}
-
City: ${safe(props.assigned_city || '—')}
-
Speed: ${props.speed_kmh ?? '—'} km/h
-
Last fix: ${safe(props.occurred_at)}
+ `; } @@ -169,10 +270,8 @@ function _popupHtml(props) { export function renderView(map, payload, { summaryRoot, sloRoot } = {}) { if (!payload || !payload.geojson) return; - const src = map.getSource(VEHICLE_SOURCE); if (src) src.setData(payload.geojson); - if (summaryRoot) _renderSummary(summaryRoot, payload.summary || {}); if (sloRoot) _renderSlos(sloRoot, payload.slo_status || {}); } @@ -186,14 +285,11 @@ function _renderSummary(root, summary) { { label: 'Below freshness SLO', value: summary.below_freshness_slo ?? '—' }, ]; root.innerHTML = tiles - .map( - (t) => ` -
-
${t.label}
-
${t.value}
-
`, - ) - .join(''); + .map(t => ` +
+
${t.label}
+
${t.value}
+
`).join(''); } function _renderSlos(root, slos) { @@ -202,19 +298,17 @@ function _renderSlos(root, slos) { root.innerHTML = '
SLO data not yet available
'; return; } - root.innerHTML = entries - .map(([metric, info]) => { - const status = info.status || 'unknown'; - const current = info.current ?? '—'; - const threshold = info.threshold ?? '—'; - return ` -
- ${metric} - ${current} / ${threshold} - ${status} -
`; - }) - .join(''); + root.innerHTML = entries.map(([metric, info]) => { + const status = info.status || 'unknown'; + const current = info.current ?? '—'; + const threshold = info.threshold ?? '—'; + return ` +
+ ${metric} + ${current} / ${threshold} + ${status} +
`; + }).join(''); } /* ---------- filters ---------- */ @@ -223,16 +317,11 @@ export function initFilters(formEl, onChange) { const handler = () => { const fd = new FormData(formEl); const filters = {}; - for (const [k, v] of fd.entries()) { - if (v) filters[k] = v; - } + for (const [k, v] of fd.entries()) if (v) filters[k] = v; onChange(filters); }; formEl.addEventListener('change', handler); - formEl.addEventListener('submit', (e) => { - e.preventDefault(); - handler(); - }); + formEl.addEventListener('submit', (e) => { e.preventDefault(); handler(); }); } /* ---------- clockEAT ---------- */ @@ -241,16 +330,11 @@ export function clockEAT(elementId) { const el = document.getElementById(elementId); if (!el) return; const tick = () => { - const now = new Date(); - const eat = new Date(now.getTime()); const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: 'Africa/Nairobi', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); - el.textContent = `${fmt.format(eat)} EAT`; + el.textContent = `${fmt.format(new Date())} EAT`; }; tick(); setInterval(tick, 1000); diff --git a/web/index-live.html b/web/index-live.html index 3c3f841..b31fa45 100644 --- a/web/index-live.html +++ b/web/index-live.html @@ -40,6 +40,31 @@ .slo-red .slo-status { color: var(--bad); } .slo-unknown .slo-status { color: var(--muted); } .slo-empty { color: var(--muted); font-size: 12px; } + + /* hover popup matching the dark theme */ + .fleet-popup .maplibregl-popup-content { + background: #1e293b !important; + color: var(--text) !important; + padding: 14px 16px !important; + border-radius: 8px !important; + min-width: 240px; + box-shadow: 0 12px 32px rgba(0,0,0,0.55); + border: 1px solid #0b1220; + } + .fleet-popup .maplibregl-popup-tip { display: none; } + .popup-card { font-family: inherit; } + .popup-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } + .popup-plate { font-size: 16px; font-weight: 700; letter-spacing: 0.01em; } + .popup-pill { + font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; + padding: 3px 8px; border-radius: 4px; font-weight: 600; + } + .pill-moving { background: rgba(16,185,129,0.18); color: var(--accent); } + .pill-parked { background: rgba(148,163,184,0.15); color: var(--muted); } + .pill-offline { background: rgba(148,163,184,0.15); color: var(--muted); } + .pill-unknown { background: rgba(148,163,184,0.15); color: var(--muted); } + .popup-meta { color: var(--muted); font-size: 12px; margin: 2px 0 8px; } + .popup-row { color: #cbd5e1; font-size: 12.5px; margin: 4px 0; } form.filters { display: grid; gap: 8px; } form.filters input, form.filters select { width: 100%; background: #0b1220; color: var(--text); border: 1px solid #0b1220; border-radius: 4px; padding: 6px 8px; font-size: 12px; } form.filters label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; }