fleet-platform/web/fleet-core.js
kianiadee 9852eff985
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions
Popup: pre-emptive driver-name extraction from device_name (until P3 roster lands)
2026-05-23 23:13:46 +03:00

344 lines
11 KiB
JavaScript

// fleet-core.js
// Shared client primitives for the fleet-platform dashboards.
//
// 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 STORAGE_ACCESS = 'fleet.accessToken';
const STORAGE_REFRESH = 'fleet.refreshToken';
const STORAGE_EXPIRES = 'fleet.expiresAt';
const VEHICLE_SOURCE = 'vehicles';
/* ---------- authClient ---------- */
export const authClient = {
isAuthenticated() {
const expiresAt = Number(localStorage.getItem(STORAGE_EXPIRES) || 0);
return localStorage.getItem(STORAGE_ACCESS) !== null && Date.now() < expiresAt * 1000;
},
async login(username, password) {
const body = new URLSearchParams({ username, password });
const res = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: 'login failed' }));
throw new Error(detail.detail || 'login failed');
}
const payload = await res.json();
const expiresAt = Math.floor(Date.now() / 1000) + Number(payload.expires_in || 900);
localStorage.setItem(STORAGE_ACCESS, payload.access_token);
localStorage.setItem(STORAGE_REFRESH, payload.refresh_token);
localStorage.setItem(STORAGE_EXPIRES, String(expiresAt));
},
logout() {
localStorage.removeItem(STORAGE_ACCESS);
localStorage.removeItem(STORAGE_REFRESH);
localStorage.removeItem(STORAGE_EXPIRES);
},
requireSession({ loginPath = '/login.html' } = {}) {
if (!this.isAuthenticated()) {
window.location.href = loginPath;
return false;
}
return true;
},
};
export async function apiFetch(path, { params, ...opts } = {}) {
const url = new URL(path, window.location.origin);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') {
url.searchParams.set(k, typeof v === 'string' ? v : JSON.stringify(v));
}
}
}
const token = localStorage.getItem(STORAGE_ACCESS);
const res = await fetch(url.toString(), {
...opts,
headers: {
...(opts.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
});
if (res.status === 401) {
authClient.logout();
window.location.href = '/login.html';
throw new Error('unauthorized');
}
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
/* ---------- map setup ---------- */
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/dark-matter-gl-style/style.json';
// eslint-disable-next-line no-undef
const map = new maplibregl.Map({
container: elementId,
style: styleUrl,
center,
zoom,
attributionControl: true,
});
map.on('load', () => {
_addArrowImage(map);
map.addSource(VEHICLE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addLayer({
id: 'vehicles-circle',
type: 'circle',
source: VEHICLE_SOURCE,
paint: {
'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.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 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 addressLine = props.address_short || props.address || null;
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 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>
${props.driver_name ? `<div class="popup-driver">${_esc(props.driver_name)}</div>` : ''}
${tagLine ? `<div class="popup-meta">${_esc(tagLine)}</div>` : ''}
${addressLine ? `<div class="popup-address">${_esc(addressLine)}</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>
`;
}
/* ---------- render ---------- */
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 || {});
}
function _renderSummary(root, summary) {
const tiles = [
{ label: 'Active', value: summary.total_active ?? '—' },
{ label: 'Moving', value: summary.moving ?? '—' },
{ label: 'Parked', value: summary.parked ?? '—' },
{ label: 'Offline', value: summary.offline ?? '—' },
{ label: 'Below freshness SLO', value: summary.below_freshness_slo ?? '—' },
];
root.innerHTML = tiles
.map(t => `
<div class="tile">
<div class="tile-label">${t.label}</div>
<div class="tile-value">${t.value}</div>
</div>`).join('');
}
function _renderSlos(root, slos) {
const entries = Object.entries(slos);
if (entries.length === 0) {
root.innerHTML = '<div class="slo-empty">SLO data not yet available</div>';
return;
}
root.innerHTML = entries.map(([metric, info]) => {
const status = info.status || 'unknown';
const current = info.current ?? '—';
const threshold = info.threshold ?? '—';
return `
<div class="slo slo-${status}">
<span class="slo-name">${metric}</span>
<span class="slo-value">${current} / ${threshold}</span>
<span class="slo-status">${status}</span>
</div>`;
}).join('');
}
/* ---------- filters ---------- */
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;
onChange(filters);
};
formEl.addEventListener('change', handler);
formEl.addEventListener('submit', (e) => { e.preventDefault(); handler(); });
}
/* ---------- clockEAT ---------- */
export function clockEAT(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
const tick = () => {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Africa/Nairobi',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
el.textContent = `${fmt.format(new Date())} EAT`;
};
tick();
setInterval(tick, 1000);
}