343 lines
11 KiB
JavaScript
343 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>
|
|
${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);
|
|
}
|