fleetops/src/index.html
david kiania 71f40e8c62 test: build marker to verify Forgejo->Coolify auto-deploy
Harmless <meta> marker to confirm a push to staging triggers a Coolify
redeploy. Safe to revert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:06:34 +03:00

457 lines
20 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FleetOps — Fleet Operations</title>
<meta name="build-marker" content="autodeploy-test-1">
<!--
FleetOps — fleet operations analytics (fuel · utilisation · driver behaviour).
Sibling to FleetNow (live tracking); reuses the same warm-dark ops palette so
the two feel like one product. Self-contained single file + Chart.js (CDN).
Reads the FleetOps analytics API (dashboard_api /analytics/*):
GET <API_BASE>/analytics/filters → dropdown options
GET <API_BASE>/analytics/fleet-summary → totals + per-vehicle rows
GET <API_BASE>/analytics/utilisation → per-vehicle + daily_trend
GET <API_BASE>/analytics/driver-behaviour → per-driver speeding/harsh
GET <API_BASE>/analytics/fuel → actual vs estimated litres
API_BASE is injected at runtime by Caddy via /env.js (see Caddyfile).
-->
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<script src="/env.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root {
/* Shared with FleetNow (warm dark ops palette) */
--bg: #161a23;
--panel: #1e232e;
--panel-2: #232a36;
--border: #2c333f;
--text: #ECEFF4;
--muted: #93a0b4;
--accent: #E8954A; /* amber — primary, brand, focus */
--accent-hover:#d97b2c;
--live: #2dd4a7; /* teal-green — good / active */
--parked: #6b7280;
--offline: #b4791f;
--warn: #f0a93b;
--danger: #ef5b5b;
--error-bg: #2a0a0a;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text);
}
.app {
display: grid; min-height: 100vh;
grid-template-rows: auto auto 1fr; /* header · filter bar · content */
}
/* ── Top bar (mirrors FleetNow) ──────────────────────────────────────── */
header {
padding: 9px 18px; background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
position: sticky; top: 0; z-index: 20;
}
.brand {
font-weight: 800; letter-spacing: .5px; font-size: 16px;
display: flex; align-items: center; gap: 8px; white-space: nowrap;
}
.brand .mark {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 10px var(--accent);
}
.brand .nm { color: var(--accent); }
#kpis { display: flex; gap: 22px; flex-wrap: wrap; align-items: center; }
.kpi { display: flex; flex-direction: column; min-width: 56px; }
.kpi b { font-size: 18px; line-height: 1.15; font-variant-numeric: tabular-nums; }
.kpi b.accent { color: var(--accent); }
.kpi b.live { color: var(--live); }
.kpi b.warn { color: var(--warn); }
.kpi span {
font-size: 9.5px; color: var(--muted); text-transform: uppercase;
letter-spacing: .6px; margin-top: 2px;
}
.spacer { margin-left: auto; }
.clock {
color: var(--text); font-size: 14px; font-variant-numeric: tabular-nums;
display: flex; flex-direction: column; align-items: flex-end;
}
.clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
.clock b { font-weight: 600; }
/* ── Filter bar ──────────────────────────────────────────────────────── */
.filterbar {
padding: 10px 18px; background: var(--panel-2);
border-bottom: 1px solid var(--border);
display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap;
position: sticky; top: 49px; z-index: 19;
}
.ff { display: flex; flex-direction: column; gap: 4px; }
.ff label { font-size: 9.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); }
.ff select, .ff input {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 9px; font-size: 13px; min-width: 150px;
}
.ff select:focus, .ff input:focus { outline: none; border-color: var(--accent); }
.btn {
background: var(--accent); color: #1a1009; font-weight: 700; font-size: 13px;
border: 0; border-radius: 6px; padding: 8px 16px; cursor: pointer;
}
.btn:hover { background: var(--accent-hover); }
.btn.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn.ghost:hover { color: var(--text); border-color: var(--muted); }
.ff.custom { display: none; }
.ff.custom.show { display: flex; }
/* ── Content grid ────────────────────────────────────────────────────── */
main { padding: 16px 18px 40px; display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 14px 16px; min-width: 0;
}
.card.span12 { grid-column: span 12; }
.card.span8 { grid-column: span 8; }
.card.span6 { grid-column: span 6; }
.card.span4 { grid-column: span 4; }
@media (max-width: 1100px) { .card.span8, .card.span6, .card.span4 { grid-column: span 12; } }
.card h2 {
margin: 0 0 12px; font-size: 12px; text-transform: uppercase; letter-spacing: .8px;
color: var(--muted); font-weight: 700; display: flex; align-items: center; gap: 8px;
}
.card h2 .count { color: var(--accent); font-weight: 700; }
.chart-wrap { position: relative; height: 280px; }
/* ── Tables ──────────────────────────────────────────────────────────── */
.tbl-scroll { overflow: auto; max-height: 420px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
position: sticky; top: 0; background: var(--panel-2); color: var(--muted);
text-align: right; font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
padding: 8px 10px; white-space: nowrap; border-bottom: 1px solid var(--border);
}
thead th:first-child, tbody td:first-child { text-align: left; }
tbody td {
padding: 7px 10px; text-align: right; border-bottom: 1px solid var(--border);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
tbody tr:hover { background: var(--panel-2); }
td.plate { font-weight: 600; color: var(--text); }
td.dim { color: var(--muted); }
.pill {
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px;
font-weight: 600; font-variant-numeric: tabular-nums;
}
.pill.good { background: rgba(45,212,167,.14); color: var(--live); }
.pill.warn { background: rgba(240,169,59,.14); color: var(--warn); }
.pill.bad { background: rgba(239,91,91,.16); color: var(--danger); }
.empty { color: var(--muted); padding: 24px; text-align: center; font-size: 13px; }
.banner {
background: rgba(240,169,59,.10); border: 1px solid rgba(240,169,59,.35);
color: var(--warn); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; margin-bottom: 12px;
}
.banner ul { margin: 6px 0 0; padding-left: 18px; }
.banner.error { background: var(--error-bg); border-color: rgba(239,91,91,.45); color: var(--danger); }
.loading { opacity: .45; pointer-events: none; }
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><span class="mark"></span>FLEET<span class="nm">OPS</span></div>
<div id="kpis"></div>
<div class="spacer"></div>
<div class="clock"><span class="label">EAT</span><b id="clock-time"></b></div>
</header>
<div class="filterbar">
<div class="ff">
<label for="f-cc">Cost centre</label>
<select id="f-cc"><option value="">All cost centres</option></select>
</div>
<div class="ff">
<label for="f-city">Assigned city</label>
<select id="f-city"><option value="">All cities</option></select>
</div>
<div class="ff">
<label for="f-period">Period</label>
<select id="f-period">
<option value="today">Today</option>
<option value="7d">Last 1 week</option>
<option value="30d" selected>Last 1 month</option>
<option value="custom">Custom range</option>
</select>
</div>
<div class="ff custom" id="ff-start"><label for="f-start">Start</label><input type="date" id="f-start"></div>
<div class="ff custom" id="ff-end"><label for="f-end">End</label><input type="date" id="f-end"></div>
<button class="btn" id="apply" type="button">Apply</button>
<button class="btn ghost" id="refresh" type="button" title="Reload"></button>
</div>
<main id="main">
<div class="card span12">
<h2>Distance &amp; idle — daily trend</h2>
<div class="chart-wrap"><canvas id="trendChart"></canvas></div>
</div>
<div class="card span8">
<h2>Per-vehicle summary <span class="count" id="veh-count"></span></h2>
<div class="tbl-scroll" id="veh-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span4">
<h2>Fuel <span class="count" id="fuel-count"></span></h2>
<div id="fuel-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span12">
<h2>Driver behaviour — 30-day leaderboard <span class="count" id="drv-count"></span></h2>
<div class="tbl-scroll" id="drv-wrap"><div class="empty">Loading…</div></div>
</div>
</main>
</div>
<script>
// ============================================================================
// CONFIG
// ============================================================================
const API_BASE = (window.FLEETOPS_API_BASE && /^https?:\/\//.test(window.FLEETOPS_API_BASE))
? window.FLEETOPS_API_BASE.replace(/\/$/, '')
: 'https://fleetapi.fivetitude.com'; // staging default
// ============================================================================
// HELPERS
// ============================================================================
const $ = (id) => document.getElementById(id);
const num = (v, d = 0) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en', { minimumFractionDigits: d, maximumFractionDigits: d });
const intg = (v) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en');
async function api(path) {
const r = await fetch(`${API_BASE}${path}`, { headers: { 'Accept': 'application/json' } });
if (!r.ok) throw new Error(`${path} → HTTP ${r.status}`);
const j = await r.json();
if (j && j.error) throw new Error(j.error.message || 'API error');
return j;
}
function qs() {
const p = new URLSearchParams();
p.set('period', $('f-period').value);
if ($('f-cc').value) p.set('cost_centre', $('f-cc').value);
if ($('f-city').value) p.set('assigned_city', $('f-city').value);
if ($('f-period').value === 'custom') {
if ($('f-start').value) p.set('start_date', $('f-start').value);
if ($('f-end').value) p.set('end_date', $('f-end').value);
}
return p.toString();
}
function idlePill(pct) {
if (pct == null) return '<span class="dim">—</span>';
const cls = pct < 15 ? 'good' : pct <= 30 ? 'warn' : 'bad';
return `<span class="pill ${cls}">${num(pct, 1)}%</span>`;
}
function ratePill(v, green, amber) {
if (v == null) return '<span class="dim">—</span>';
const cls = v < green ? 'good' : v <= amber ? 'warn' : 'bad';
return `<span class="pill ${cls}">${num(v, 2)}</span>`;
}
// ============================================================================
// CLOCK
// ============================================================================
function tickClock() {
const t = new Date().toLocaleTimeString('en-GB', { timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', second: '2-digit' });
$('clock-time').textContent = t;
}
setInterval(tickClock, 1000); tickClock();
// ============================================================================
// FILTERS
// ============================================================================
async function loadFilters() {
try {
const f = await api('/analytics/filters');
const cc = $('f-cc'), city = $('f-city');
(f.cost_centres || []).forEach(v => cc.add(new Option(v, v)));
(f.cities || []).forEach(v => city.add(new Option(v, v)));
} catch (e) { console.warn('filters', e); }
}
$('f-period').addEventListener('change', () => {
const custom = $('f-period').value === 'custom';
$('ff-start').classList.toggle('show', custom);
$('ff-end').classList.toggle('show', custom);
});
$('apply').addEventListener('click', loadAll);
$('refresh').addEventListener('click', loadAll);
// ============================================================================
// RENDER — KPIs
// ============================================================================
function renderKpis(totals, fuelL) {
totals = totals || {};
const kpis = [
['accent', intg(totals.vehicles), 'Vehicles'],
['', num(totals.total_km, 0), 'Fleet km'],
['', intg(totals.trips), 'Trips'],
['', num(totals.driving_hours, 0), 'Drive hrs'],
['warn', num(totals.idle_hours, 0), 'Idle hrs'],
['live', fuelL > 0 ? num(fuelL, 0) : '—', 'Fuel L'],
];
$('kpis').innerHTML = kpis.map(([cls, v, l]) =>
`<div class="kpi"><b class="${cls}">${v}</b><span>${l}</span></div>`).join('');
}
// ============================================================================
// RENDER — trend chart
// ============================================================================
let trendChart = null;
function renderTrend(daily) {
daily = daily || [];
const labels = daily.map(d => d.trip_date);
const km = daily.map(d => Number(d.total_km || 0));
const idle = daily.map(d => d.idle_pct == null ? null : Number(d.idle_pct));
const css = getComputedStyle(document.documentElement);
const accent = css.getPropertyValue('--accent').trim();
const warn = css.getPropertyValue('--warn').trim();
const muted = css.getPropertyValue('--muted').trim();
const border = css.getPropertyValue('--border').trim();
const data = {
labels,
datasets: [
{ type: 'bar', label: 'Fleet km', data: km, backgroundColor: accent + 'cc', borderRadius: 3, yAxisID: 'y', order: 2 },
{ type: 'line', label: 'Idle %', data: idle, borderColor: warn, backgroundColor: warn, tension: .3, pointRadius: 2, yAxisID: 'y1', order: 1, spanGaps: true },
],
};
const scale = (id, pos, title, pct) => ({
position: pos, grid: { color: border, drawOnChartArea: id === 'y' }, border: { color: border },
ticks: { color: muted, callback: v => pct ? v + '%' : Number(v).toLocaleString('en') },
title: { display: true, text: title, color: muted, font: { size: 10 } }, beginAtZero: true,
});
const opts = {
responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: muted, boxWidth: 12, font: { size: 11 } } } },
scales: { x: { grid: { color: border }, border: { color: border }, ticks: { color: muted, maxRotation: 0, autoSkip: true } },
y: scale('y', 'left', 'km', false), y1: scale('y1', 'right', 'idle %', true) },
};
if (trendChart) { trendChart.data = data; trendChart.options = opts; trendChart.update(); }
else trendChart = new Chart($('trendChart'), { data, options: opts });
}
// ============================================================================
// RENDER — per-vehicle table
// ============================================================================
function renderVehicles(rows) {
rows = rows || [];
$('veh-count').textContent = rows.length ? `(${rows.length})` : '';
if (!rows.length) { $('veh-wrap').innerHTML = '<div class="empty">No trips in range.</div>'; return; }
const body = rows.map(r => `
<tr>
<td class="plate">${r.vehicle_number ?? '—'}</td>
<td class="dim">${r.cost_centre ?? '—'}</td>
<td class="dim">${r.assigned_city ?? '—'}</td>
<td>${intg(r.trips)}</td>
<td>${num(r.total_km, 1)}</td>
<td>${num(r.driving_hours, 1)}</td>
<td>${idlePill(r.idle_pct)}</td>
<td>${num(r.max_speed_kmh, 0)}</td>
</tr>`).join('');
$('veh-wrap').innerHTML = `<table>
<thead><tr><th>Vehicle</th><th>Cost centre</th><th>City</th><th>Trips</th><th>km</th><th>Drive h</th><th>Idle</th><th>Max km/h</th></tr></thead>
<tbody>${body}</tbody></table>`;
}
// ============================================================================
// RENDER — driver behaviour
// ============================================================================
function renderDrivers(d) {
const rows = (d && d.rows) || [];
$('drv-count').textContent = rows.length ? `(${rows.length})` : '';
if (!rows.length) {
const why = (d && d.drivers_populated === false) ? 'Driver names not yet populated on devices.' : 'No driver activity in range.';
$('drv-wrap').innerHTML = `<div class="empty">${why}</div>`; return;
}
const body = rows.map(r => `
<tr>
<td class="plate">${r.driver_name ?? '—'}</td>
<td class="dim">${r.assigned_city ?? '—'}</td>
<td>${intg(r.active_days)}</td>
<td>${num(r.total_km, 0)}</td>
<td>${intg(r.trips)}</td>
<td>${intg(r.events_80)}</td>
<td>${intg(r.events_100)}</td>
<td>${ratePill(r.speeding_per_100km, 0.5, 2.0)}</td>
<td>${ratePill(r.harsh_per_100km, 0.5, 2.0)}</td>
</tr>`).join('');
$('drv-wrap').innerHTML = `<table>
<thead><tr><th>Driver</th><th>City</th><th>Days</th><th>km</th><th>Trips</th><th>&gt;80</th><th>&gt;100</th><th>Speeding /100km</th><th>Harsh /100km</th></tr></thead>
<tbody>${body}</tbody></table>`;
}
// ============================================================================
// RENDER — fuel
// ============================================================================
function renderFuel(d) {
const rows = (d && d.rows) || [];
const ds = (d && d.data_status) || {};
$('fuel-count').textContent = rows.length ? `(${rows.length})` : '';
let html = '';
if (!ds.actual_fuel_available && !ds.estimated_fuel_available) {
html += `<div class="banner">No fuel figures yet.<ul>${(ds.notes || []).map(n => `<li>${n}</li>`).join('')}</ul></div>`;
}
const actual = rows.reduce((s, r) => s + Number(r.actual_fuel_l || 0), 0);
const est = rows.reduce((s, r) => s + Number(r.estimated_fuel_l || 0), 0);
const km = rows.reduce((s, r) => s + Number(r.total_km || 0), 0);
html += `<table>
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
<tbody>
<tr><td>Total km</td><td>${num(km, 0)}</td></tr>
<tr><td>Actual fuel (L)</td><td>${actual > 0 ? num(actual, 1) : '—'}</td></tr>
<tr><td>Estimated fuel (L)</td><td>${est > 0 ? num(est, 1) : '—'}</td></tr>
<tr><td>Trips w/ actual</td><td>${intg(rows.reduce((s, r) => s + Number(r.trips_with_actual || 0), 0))}</td></tr>
</tbody></table>`;
$('fuel-wrap').innerHTML = html;
}
// ============================================================================
// LOAD ALL
// ============================================================================
async function loadAll() {
const q = qs();
$('main').classList.add('loading');
try {
const [summary, util, drivers, fuel] = await Promise.all([
api(`/analytics/fleet-summary?${q}`),
api(`/analytics/utilisation?${q}`),
api(`/analytics/driver-behaviour?${q}`),
api(`/analytics/fuel?${q}`),
]);
const fuelL = ((fuel && fuel.rows) || []).reduce((s, r) => s + Number(r.actual_fuel_l || 0), 0);
renderKpis(summary.totals, fuelL);
renderTrend(util.daily_trend);
renderVehicles(summary.rows);
renderDrivers(drivers);
renderFuel(fuel);
} catch (e) {
console.error(e);
$('main').querySelectorAll('.tbl-scroll, #fuel-wrap').forEach(el =>
el.innerHTML = `<div class="banner error">${e.message || 'Failed to load. Is the API reachable?'}</div>`);
} finally {
$('main').classList.remove('loading');
}
}
// ============================================================================
// BOOT
// ============================================================================
(async () => { await loadFilters(); await loadAll(); })();
</script>
</body>
</html>