UI: arrow + plate-short label + cost-centre marker palette + hover popup; richer state.live_positions + serve.fn_live_view v2; multi-target poll plumbing
This commit is contained in:
parent
13a4c17d80
commit
6c5ba3b22b
10 changed files with 517 additions and 129 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -102,14 +102,18 @@ 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,
|
||||
|
|
@ -120,16 +124,28 @@ async def _project_one(
|
|||
acc_state = EXCLUDED.acc_state,
|
||||
source = EXCLUDED.source,
|
||||
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
|
||||
""",
|
||||
(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
for target in targets:
|
||||
try:
|
||||
body = await client.location_list(target=target)
|
||||
except TracksolidError:
|
||||
log.exception("poller.list_api_error")
|
||||
return
|
||||
log.exception("poller.list_api_error", target=target)
|
||||
continue
|
||||
except Exception:
|
||||
log.exception("poller.list_crashed")
|
||||
return
|
||||
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)
|
||||
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))
|
||||
|
||||
|
|
|
|||
31
db/migrations/20260601000008_live_positions_richer.sql
Normal file
31
db/migrations/20260601000008_live_positions_richer.sql
Normal 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;
|
||||
172
db/migrations/20260601000009_serve_fn_live_view_v2.sql
Normal file
172
db/migrations/20260601000009_serve_fn_live_view_v2.sql
Normal 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);
|
||||
|
|
@ -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_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 = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<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 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 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.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('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-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,
|
||||
},
|
||||
});
|
||||
|
||||
map.on('mouseenter', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = 'pointer'));
|
||||
map.on('mouseleave', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = ''));
|
||||
_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 `
|
||||
<div style="font-family:system-ui,sans-serif;font-size:13px;line-height:1.4">
|
||||
<div><strong>${safe(props.plate)}</strong> · ${safe(props.operational_state)}</div>
|
||||
<div>Cost centre: ${safe(props.cost_centre || '—')}</div>
|
||||
<div>City: ${safe(props.assigned_city || '—')}</div>
|
||||
<div>Speed: ${props.speed_kmh ?? '—'} km/h</div>
|
||||
<div>Last fix: ${safe(props.occurred_at)}</div>
|
||||
<div class="popup-card">
|
||||
<div class="popup-header">
|
||||
<strong class="popup-plate">${_esc(props.plate)}</strong>
|
||||
<span class="popup-pill pill-${_esc(state)}">${_esc(pillText)}</span>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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) => `
|
||||
.map(t => `
|
||||
<div class="tile">
|
||||
<div class="tile-label">${t.label}</div>
|
||||
<div class="tile-value">${t.value}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join('');
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function _renderSlos(root, slos) {
|
||||
|
|
@ -202,8 +298,7 @@ function _renderSlos(root, slos) {
|
|||
root.innerHTML = '<div class="slo-empty">SLO data not yet available</div>';
|
||||
return;
|
||||
}
|
||||
root.innerHTML = entries
|
||||
.map(([metric, info]) => {
|
||||
root.innerHTML = entries.map(([metric, info]) => {
|
||||
const status = info.status || 'unknown';
|
||||
const current = info.current ?? '—';
|
||||
const threshold = info.threshold ?? '—';
|
||||
|
|
@ -213,8 +308,7 @@ function _renderSlos(root, slos) {
|
|||
<span class="slo-value">${current} / ${threshold}</span>
|
||||
<span class="slo-status">${status}</span>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
}).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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue