257 lines
7.7 KiB
JavaScript
257 lines
7.7 KiB
JavaScript
// 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
|
|
|
|
const API_BASE = '';
|
|
const STORAGE_ACCESS = 'fleet.accessToken';
|
|
const STORAGE_REFRESH = 'fleet.refreshToken';
|
|
const STORAGE_EXPIRES = 'fleet.expiresAt';
|
|
|
|
/* ---------- 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_BASE}/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 ---------- */
|
|
|
|
const STYLE_CLASS_COLORS = {
|
|
'vehicle-moving': '#10b981',
|
|
'vehicle-parked': '#3b82f6',
|
|
'vehicle-offline': '#9ca3af',
|
|
};
|
|
|
|
const VEHICLE_SOURCE = 'vehicles';
|
|
const VEHICLE_LAYER = 'vehicles-circle';
|
|
|
|
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';
|
|
|
|
// eslint-disable-next-line no-undef
|
|
const map = new maplibregl.Map({
|
|
container: elementId,
|
|
style: styleUrl,
|
|
center,
|
|
zoom,
|
|
attributionControl: true,
|
|
});
|
|
|
|
map.on('load', () => {
|
|
map.addSource(VEHICLE_SOURCE, {
|
|
type: 'geojson',
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
});
|
|
map.addLayer({
|
|
id: VEHICLE_LAYER,
|
|
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,
|
|
},
|
|
});
|
|
});
|
|
|
|
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.on('mouseenter', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = 'pointer'));
|
|
map.on('mouseleave', VEHICLE_LAYER, () => (map.getCanvas().style.cursor = ''));
|
|
|
|
return map;
|
|
}
|
|
|
|
function _popupHtml(props) {
|
|
const safe = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
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>
|
|
`;
|
|
}
|
|
|
|
/* ---------- 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 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,
|
|
});
|
|
el.textContent = `${fmt.format(eat)} EAT`;
|
|
};
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
}
|