UI: arrow + plate-short label + cost-centre marker palette + hover popup; richer state.live_positions + serve.fn_live_view v2; multi-target poll plumbing
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions

This commit is contained in:
kianiadee 2026-05-23 09:29:04 +03:00
parent 13a4c17d80
commit 6c5ba3b22b
10 changed files with 517 additions and 129 deletions

View file

@ -21,6 +21,9 @@ TRACKSOLID_APP_SECRET=
TRACKSOLID_USER_ID= TRACKSOLID_USER_ID=
TRACKSOLID_PWD_MD5= TRACKSOLID_PWD_MD5=
TRACKSOLID_TARGET_ACCOUNT= 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_TOKEN_TTL_SEC=7200
TRACKSOLID_POLL_INTERVAL_SEC=60 TRACKSOLID_POLL_INTERVAL_SEC=60
TRACKSOLID_STALE_POLL_INTERVAL_SEC=600 TRACKSOLID_STALE_POLL_INTERVAL_SEC=600

View file

@ -21,6 +21,7 @@ class Settings(BaseSettings):
tracksolid_user_id: str = Field(default="", alias="TRACKSOLID_USER_ID") tracksolid_user_id: str = Field(default="", alias="TRACKSOLID_USER_ID")
tracksolid_pwd_md5: str = Field(default="", alias="TRACKSOLID_PWD_MD5") tracksolid_pwd_md5: str = Field(default="", alias="TRACKSOLID_PWD_MD5")
tracksolid_target_account: str = Field(default="", alias="TRACKSOLID_TARGET_ACCOUNT") 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_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_poll_interval_sec: int = Field(default=60, alias="TRACKSOLID_POLL_INTERVAL_SEC")
tracksolid_stale_poll_interval_sec: int = Field( tracksolid_stale_poll_interval_sec: int = Field(

View file

@ -49,7 +49,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
async def _run_stale() -> None: async def _run_stale() -> None:
await poller.poll_stale_imeis(client, settings) 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( scheduler.add_job(
_run_list, _run_list,
trigger=IntervalTrigger(seconds=settings.tracksolid_poll_interval_sec), trigger=IntervalTrigger(seconds=settings.tracksolid_poll_interval_sec),

View file

@ -61,6 +61,11 @@ def _fix_payload(
satellites: int | None = None, satellites: int | None = None,
acc: Any = None, acc: Any = None,
source: str, 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]: ) -> dict[str, Any]:
return { return {
"lat": lat, "lat": lat,
@ -71,6 +76,11 @@ def _fix_payload(
"satellites": satellites, "satellites": satellites,
"acc": acc, "acc": acc,
"source": source, "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: try:
model = JimiPollFix.model_validate(item) model = JimiPollFix.model_validate(item)
except Exception: except Exception:
# malformed item — don't fail the whole batch; logged at parser
continue continue
if model.gps_time is None: if model.gps_time is None:
continue continue
if not _is_valid_fix(model.lat, model.lng): if not _is_valid_fix(model.lat, model.lng):
continue continue
assert model.lat is not None and model.lng is not None 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( out.append(
ParsedEvent( ParsedEvent(
kind="position_fix", kind="position_fix",
@ -229,6 +255,11 @@ def _parse_poll_list(payload: dict[str, Any], account_id: str | None) -> list[Pa
satellites=model.satellites, satellites=model.satellites,
acc=model.acc, acc=model.acc,
source="tracksolid_poll", 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,
), ),
) )
) )

View file

@ -102,34 +102,50 @@ async def _project_one(
geom_wkt = f"POINT({lng} {lat})" geom_wkt = f"POINT({lng} {lat})"
acc_int = payload.get("acc") if isinstance(payload.get("acc"), int) else None
await cur.execute( await cur.execute(
""" """
INSERT INTO state.live_positions ( INSERT INTO state.live_positions (
imei, vehicle_id, occurred_at, geom, speed_kmh, direction_deg, 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 ( ) VALUES (
%s, %s, %s, ST_SetSRID(ST_GeomFromText(%s), 4326), %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 ON CONFLICT (imei) DO UPDATE
SET vehicle_id = EXCLUDED.vehicle_id, SET vehicle_id = EXCLUDED.vehicle_id,
occurred_at = EXCLUDED.occurred_at, occurred_at = EXCLUDED.occurred_at,
geom = EXCLUDED.geom, geom = EXCLUDED.geom,
speed_kmh = EXCLUDED.speed_kmh, speed_kmh = EXCLUDED.speed_kmh,
direction_deg = EXCLUDED.direction_deg, direction_deg = EXCLUDED.direction_deg,
acc_state = EXCLUDED.acc_state, acc_state = EXCLUDED.acc_state,
source = EXCLUDED.source, source = EXCLUDED.source,
parser_version = EXCLUDED.parser_version, parser_version = EXCLUDED.parser_version,
updated_at = now() 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 WHERE EXCLUDED.occurred_at > state.live_positions.occurred_at
""", """,
( (
imei, vehicle_id, occurred_at, geom_wkt, imei, vehicle_id, occurred_at, geom_wkt,
payload.get("speed_kmh"), payload.get("speed_kmh"),
payload.get("direction_deg"), payload.get("direction_deg"),
payload.get("acc") if isinstance(payload.get("acc"), int) else None, acc_int,
payload.get("source") or "unknown", payload.get("source") or "unknown",
PARSER_VERSION, 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 ( INSERT INTO state.position_history (
vehicle_id, imei, occurred_at, geom, speed_kmh, direction_deg, 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 ( ) VALUES (
%s, %s, %s, ST_SetSRID(ST_GeomFromText(%s), 4326), %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, vehicle_id, imei, occurred_at, geom_wkt,
payload.get("speed_kmh"), payload.get("speed_kmh"),
payload.get("direction_deg"), payload.get("direction_deg"),
payload.get("acc") if isinstance(payload.get("acc"), int) else None, acc_int,
payload.get("altitude_m"), payload.get("altitude_m"),
payload.get("satellites"), payload.get("satellites"),
payload.get("source") or "unknown", payload.get("source") or "unknown",
PARSER_VERSION, PARSER_VERSION,
payload.get("mc_type"),
payload.get("current_mileage_km"),
payload.get("gps_signal"),
payload.get("pos_type"),
), ),
) )
return True return True

View file

@ -40,23 +40,37 @@ async def _insert_raw(
return int(row[0]) 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: async def poll_live_positions(client: TracksolidClient, settings: Settings) -> None:
target = settings.tracksolid_target_account targets = _targets(settings)
if not target: if not targets:
log.warning("poller.list_skipped_no_target") log.warning("poller.list_skipped_no_targets")
return return
try: for target in targets:
body = await client.location_list(target=target) try:
except TracksolidError: body = await client.location_list(target=target)
log.exception("poller.list_api_error") except TracksolidError:
return log.exception("poller.list_api_error", target=target)
except Exception: continue
log.exception("poller.list_crashed") except Exception:
return log.exception("poller.list_crashed", target=target)
result = body.get("result") continue
n = len(result) if isinstance(result, list) else 0 result = body.get("result")
eid = await _insert_raw(source="tracksolid_poll_list", account_id=target, payload=body) n = len(result) if isinstance(result, list) else 0
log.info("poller.list_ok", event_id=eid, devices=n) 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]: 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: async def poll_stale_imeis(client: TracksolidClient, settings: Settings) -> None:
target = settings.tracksolid_target_account """jimi.device.location.get is account-scoped via the access_token, not via
if not target: `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 return
primary_target = targets[0]
imeis = await _stale_imeis(settings) imeis = await _stale_imeis(settings)
if not imeis: if not imeis:
return return
@ -96,7 +114,7 @@ async def poll_stale_imeis(client: TracksolidClient, settings: Settings) -> None
log.exception("poller.get_crashed", batch_size=len(batch)) log.exception("poller.get_crashed", batch_size=len(batch))
continue continue
eid = await _insert_raw( 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)) log.info("poller.get_ok", event_id=eid, batch_size=len(batch))

View file

@ -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;

View file

@ -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);

View file

@ -1,19 +1,21 @@
// fleet-core.js // fleet-core.js
// Shared client primitives for the fleet-platform dashboards. // 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: // Server-driven rendering per PRD §8: the API attaches `marker_color`,
// - authClient : token cache + login + apiFetch wrapper // `show_arrow`, `style_class`, etc. The JS just paints what it's told.
// - initMap : MapLibre instance + vehicle layer //
// - renderView : ingest {summary, geojson, slo_status} -> DOM + map // Markers (three MapLibre layers stacked on one source):
// - initFilters : form-driven filter UI // 1. circle — `marker_color` (cost-centre colour when moving, grey otherwise)
// - clockEAT : ticks the EAT clock element // 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_REFRESH = 'fleet.refreshToken';
const STORAGE_EXPIRES = 'fleet.expiresAt'; const STORAGE_EXPIRES = 'fleet.expiresAt';
const VEHICLE_SOURCE = 'vehicles';
/* ---------- authClient ---------- */ /* ---------- authClient ---------- */
export const authClient = { export const authClient = {
@ -24,7 +26,7 @@ export const authClient = {
async login(username, password) { async login(username, password) {
const body = new URLSearchParams({ 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', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body, body,
@ -78,29 +80,28 @@ export async function apiFetch(path, { params, ...opts } = {}) {
window.location.href = '/login.html'; window.location.href = '/login.html';
throw new Error('unauthorized'); throw new Error('unauthorized');
} }
if (!res.ok) { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
throw new Error(`${res.status} ${res.statusText}`);
}
return res.json(); return res.json();
} }
/* ---------- map ---------- */ /* ---------- map setup ---------- */
const STYLE_CLASS_COLORS = { function _addArrowImage(map) {
'vehicle-moving': '#10b981', if (map.hasImage('arrow-white')) return;
'vehicle-parked': '#3b82f6', const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
'vehicle-offline': '#9ca3af', <path d="M12 2 L19.5 19.5 L12 15.5 L4.5 19.5 Z"
}; fill="white" stroke="rgba(0,0,0,0.55)" stroke-width="1.2" stroke-linejoin="round"/>
</svg>`;
const VEHICLE_SOURCE = 'vehicles'; const img = new Image(24, 24);
const VEHICLE_LAYER = 'vehicles-circle'; 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 = {}) { 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 zoom = opts.zoom ?? 7;
const styleUrl = const styleUrl = opts.styleUrl
opts.styleUrl || || 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const map = new maplibregl.Map({ const map = new maplibregl.Map({
@ -112,55 +113,155 @@ export function initMap(elementId, opts = {}) {
}); });
map.on('load', () => { map.on('load', () => {
_addArrowImage(map);
map.addSource(VEHICLE_SOURCE, { map.addSource(VEHICLE_SOURCE, {
type: 'geojson', type: 'geojson',
data: { type: 'FeatureCollection', features: [] }, data: { type: 'FeatureCollection', features: [] },
}); });
map.addLayer({ map.addLayer({
id: VEHICLE_LAYER, id: 'vehicles-circle',
type: 'circle', type: 'circle',
source: VEHICLE_SOURCE, source: VEHICLE_SOURCE,
paint: { paint: {
'circle-radius': 7, 'circle-radius': 13,
'circle-color': [ 'circle-color': ['get', 'marker_color'],
'match', 'circle-stroke-color': '#0b1220',
['get', 'style_class'], 'circle-stroke-width': 2,
'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,
}, },
}); });
});
map.on('click', VEHICLE_LAYER, (e) => { map.addLayer({
if (!e.features || !e.features[0]) return; id: 'vehicles-arrow',
const p = e.features[0].properties; type: 'symbol',
// eslint-disable-next-line no-undef source: VEHICLE_SOURCE,
new maplibregl.Popup() filter: ['==', ['get', 'show_arrow'], true],
.setLngLat(e.features[0].geometry.coordinates) layout: {
.setHTML(_popupHtml(p)) 'icon-image': 'arrow-white',
.addTo(map); '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.addLayer({
map.on('mouseleave', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = '')); 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; 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) { 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 ` return `
<div style="font-family:system-ui,sans-serif;font-size:13px;line-height:1.4"> <div class="popup-card">
<div><strong>${safe(props.plate)}</strong> &middot; ${safe(props.operational_state)}</div> <div class="popup-header">
<div>Cost centre: ${safe(props.cost_centre || '—')}</div> <strong class="popup-plate">${_esc(props.plate)}</strong>
<div>City: ${safe(props.assigned_city || '—')}</div> <span class="popup-pill pill-${_esc(state)}">${_esc(pillText)}</span>
<div>Speed: ${props.speed_kmh ?? '—'} km/h</div> </div>
<div>Last fix: ${safe(props.occurred_at)}</div> ${tagLine ? `<div class="popup-meta">${_esc(tagLine)}</div>` : ''}
${headingLine ? `<div class="popup-row">${_esc(headingLine)}</div>` : ''}
${mileageLine ? `<div class="popup-row">${_esc(mileageLine)}</div>` : ''}
<div class="popup-row">${_esc(ageLine)}</div>
${sourceLine ? `<div class="popup-row">source ${_esc(sourceLine)}</div>` : ''}
</div> </div>
`; `;
} }
@ -169,10 +270,8 @@ function _popupHtml(props) {
export function renderView(map, payload, { summaryRoot, sloRoot } = {}) { export function renderView(map, payload, { summaryRoot, sloRoot } = {}) {
if (!payload || !payload.geojson) return; if (!payload || !payload.geojson) return;
const src = map.getSource(VEHICLE_SOURCE); const src = map.getSource(VEHICLE_SOURCE);
if (src) src.setData(payload.geojson); if (src) src.setData(payload.geojson);
if (summaryRoot) _renderSummary(summaryRoot, payload.summary || {}); if (summaryRoot) _renderSummary(summaryRoot, payload.summary || {});
if (sloRoot) _renderSlos(sloRoot, payload.slo_status || {}); if (sloRoot) _renderSlos(sloRoot, payload.slo_status || {});
} }
@ -186,14 +285,11 @@ function _renderSummary(root, summary) {
{ label: 'Below freshness SLO', value: summary.below_freshness_slo ?? '—' }, { label: 'Below freshness SLO', value: summary.below_freshness_slo ?? '—' },
]; ];
root.innerHTML = tiles root.innerHTML = tiles
.map( .map(t => `
(t) => ` <div class="tile">
<div class="tile"> <div class="tile-label">${t.label}</div>
<div class="tile-label">${t.label}</div> <div class="tile-value">${t.value}</div>
<div class="tile-value">${t.value}</div> </div>`).join('');
</div>`,
)
.join('');
} }
function _renderSlos(root, slos) { function _renderSlos(root, slos) {
@ -202,19 +298,17 @@ function _renderSlos(root, slos) {
root.innerHTML = '<div class="slo-empty">SLO data not yet available</div>'; root.innerHTML = '<div class="slo-empty">SLO data not yet available</div>';
return; return;
} }
root.innerHTML = entries root.innerHTML = entries.map(([metric, info]) => {
.map(([metric, info]) => { const status = info.status || 'unknown';
const status = info.status || 'unknown'; const current = info.current ?? '—';
const current = info.current ?? '—'; const threshold = info.threshold ?? '—';
const threshold = info.threshold ?? '—'; return `
return ` <div class="slo slo-${status}">
<div class="slo slo-${status}"> <span class="slo-name">${metric}</span>
<span class="slo-name">${metric}</span> <span class="slo-value">${current} / ${threshold}</span>
<span class="slo-value">${current} / ${threshold}</span> <span class="slo-status">${status}</span>
<span class="slo-status">${status}</span> </div>`;
</div>`; }).join('');
})
.join('');
} }
/* ---------- filters ---------- */ /* ---------- filters ---------- */
@ -223,16 +317,11 @@ export function initFilters(formEl, onChange) {
const handler = () => { const handler = () => {
const fd = new FormData(formEl); const fd = new FormData(formEl);
const filters = {}; const filters = {};
for (const [k, v] of fd.entries()) { for (const [k, v] of fd.entries()) if (v) filters[k] = v;
if (v) filters[k] = v;
}
onChange(filters); onChange(filters);
}; };
formEl.addEventListener('change', handler); formEl.addEventListener('change', handler);
formEl.addEventListener('submit', (e) => { formEl.addEventListener('submit', (e) => { e.preventDefault(); handler(); });
e.preventDefault();
handler();
});
} }
/* ---------- clockEAT ---------- */ /* ---------- clockEAT ---------- */
@ -241,16 +330,11 @@ export function clockEAT(elementId) {
const el = document.getElementById(elementId); const el = document.getElementById(elementId);
if (!el) return; if (!el) return;
const tick = () => { const tick = () => {
const now = new Date();
const eat = new Date(now.getTime());
const fmt = new Intl.DateTimeFormat('en-GB', { const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Africa/Nairobi', timeZone: 'Africa/Nairobi',
hour: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
minute: '2-digit',
second: '2-digit',
hour12: false,
}); });
el.textContent = `${fmt.format(eat)} EAT`; el.textContent = `${fmt.format(new Date())} EAT`;
}; };
tick(); tick();
setInterval(tick, 1000); setInterval(tick, 1000);

View file

@ -40,6 +40,31 @@
.slo-red .slo-status { color: var(--bad); } .slo-red .slo-status { color: var(--bad); }
.slo-unknown .slo-status { color: var(--muted); } .slo-unknown .slo-status { color: var(--muted); }
.slo-empty { color: var(--muted); font-size: 12px; } .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 { 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 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; } form.filters label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; }