fleet-platform/web/fleet-core.js

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> &middot; ${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);
}