fix(security): escape API strings, pin CDN scripts, add CSP (FO-SEC-01/02/03)
The tickets code escaped HTML, but the logistics + fuel renderers and the error banners interpolated API strings straight into innerHTML. Fuel Log fields (driver, department, fuel_type, plate) come from WhatsApp messages and vehicle/driver names from the Tracksolid registry — both user-controlled — so this was a stored-XSS path into every dispatcher's browser. - Hoist escapeHtml into HELPERS + add esc(); route every logistics/fuel renderer and the three error banners through it (21 -> 37 escaped call sites). - SRI integrity + crossorigin on Chart.js 4.4.1 and maplibre-gl 4.7.1 JS/CSS. - Caddyfile: CSP (self + pinned CDNs + CARTO basemap + the two fleet APIs), X-Content-Type-Options, Referrer-Policy, frame-ancestors 'none', -Server. Validated with `caddy validate` inside the deployed image. - loadLive(): check r.ok; pause the 15s live poll while hidden or off the Tickets tab, refresh immediately on return (FO-BUG-01/02). - Missing-API_BASE fallback flipped staging -> prod, matching the documented design (FO-OPS-01). Inline app script passes `node --check`. Audit + plan + work log in docs/260702_*. Local only; not deployed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9fb39aa992
commit
a0022fbeaf
5 changed files with 186 additions and 23 deletions
13
Caddyfile
13
Caddyfile
|
|
@ -8,6 +8,19 @@
|
|||
root * /srv
|
||||
encode zstd gzip
|
||||
|
||||
# Security headers (FO-SEC-03). CSP allows self + the two pinned CDNs, the
|
||||
# CARTO basemap (styles/tiles/fonts) and the fleet APIs; SRI in index.html
|
||||
# pins the CDN payloads themselves. frame-ancestors 'none' = no clickjacking.
|
||||
# script-src keeps 'unsafe-inline' because the whole app is one inline
|
||||
# <script> block — the CSP's job here is limiting external script origins
|
||||
# and exfil targets (connect-src), not inline policy.
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: blob: https://*.cartocdn.com; font-src https://*.cartocdn.com; connect-src 'self' https://fleetapi.rahamafresh.com https://fleetapi.fivetitude.com https://*.cartocdn.com; worker-src blob:; frame-ancestors 'none'"
|
||||
-Server
|
||||
}
|
||||
|
||||
# Health endpoint for Coolify / Traefik probes.
|
||||
handle /healthz {
|
||||
respond "ok" 200
|
||||
|
|
|
|||
67
docs/260702_audit_report.md
Normal file
67
docs/260702_audit_report.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# FleetOps — Platform Audit Report (2026-07-02)
|
||||
|
||||
Part of the 2026-07-02 cross-repo audit (tracksolid + fleettickets + fleetops; see
|
||||
the tracksolid repo's `docs/reports/260702_platform_audit_report.md` for the
|
||||
DB/host-wide findings). Scope here: the SPA (`src/index.html` + `src/env.js`),
|
||||
the Caddy/Docker serving layer, and the two deployed containers on twala.
|
||||
Companion docs: `260702_fix_plan.md`, `260702_work_done.md`.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### FO-SEC-01 — Stored XSS: API strings rendered into `innerHTML` unescaped
|
||||
The tickets explorer and map popups escape correctly (`escapeHtml`), but the
|
||||
**logistics tables (`renderVehicles`, `renderDrivers`), the fuel banner notes, and
|
||||
all three Fuel Log tables (`renderFuelVehicles`, `renderFuelDepartments`,
|
||||
`renderFuelRecent`)** interpolate API strings straight into `innerHTML`. The Fuel
|
||||
Log fields (`driver`, `department`, `fuel_type`, `plate`) originate from **WhatsApp
|
||||
messages** — genuinely user-generated content — and vehicle/driver names come from
|
||||
the Tracksolid registry, editable by any fleet-console user. A crafted name like
|
||||
`<img src=x onerror=…>` executes in every dispatcher's browser. Error banners also
|
||||
render `e.message` unescaped (server-influenced text).
|
||||
|
||||
### FO-SEC-02 — CDN scripts loaded without Subresource Integrity
|
||||
Chart.js (jsdelivr) and MapLibre (unpkg) load with no `integrity`/`crossorigin`
|
||||
attributes — a compromised or MITM'd CDN response executes arbitrary script with
|
||||
access to the fleet APIs.
|
||||
|
||||
### FO-SEC-03 — No security headers from Caddy
|
||||
No `Content-Security-Policy`, `X-Content-Type-Options`, `Referrer-Policy`, or
|
||||
frame-ancestors protection. Combined with FO-SEC-01 this leaves injected markup
|
||||
free to load external script and exfiltrate anywhere.
|
||||
|
||||
## Medium
|
||||
|
||||
### FO-BUG-01 — `loadLive()` parses the response without checking `r.ok`
|
||||
A 4xx/5xx from `/webhook/live-positions` throws inside `r.json()` on non-JSON
|
||||
bodies with an unhelpful error; harmless but noisy.
|
||||
|
||||
### FO-BUG-02 — Live-position poll never pauses
|
||||
The 15-second `/webhook/live-positions` poll starts when the Tickets map is first
|
||||
opened and then runs forever — including when the user is on the Logistics/Fuel
|
||||
tabs and when the browser tab is hidden overnight. A dashboard left open ≈ 5,760
|
||||
wasted API calls/day per viewer.
|
||||
|
||||
### FO-OPS-01 — Fallback API base pointed at *staging*
|
||||
`index.html` fell back to `https://fleetapi.fivetitude.com` when `env.js` isn't
|
||||
templated. CLAUDE.md documents the intended fallback as the **prod** API. A prod
|
||||
pod missing `API_BASE` would silently lean on the staging bridge (and fail CORS
|
||||
confusingly).
|
||||
|
||||
## Notes / by design
|
||||
|
||||
- **FO-OPS-02** — the prod container (`API_BASE=fleetapi.rahamafresh.com`) runs
|
||||
commit `21bca24`, ~19 commits behind HEAD — this is the documented frozen-prod
|
||||
staging-umbrella pattern, not drift. But note: **promotion must pair the SPA
|
||||
deploy with the prod `dashboard_api` bridge redeploy** — prod's bridge currently
|
||||
lacks all `/webhook/{inc,crq}-*` and `/analytics/fuel-fills` routes the newer
|
||||
SPA calls (see the tracksolid audit, OPS-01).
|
||||
- **FO-OPS-03** — untracked scratch files at the repo root
|
||||
(`marker-preview.html`, `tracksolid_db_connection.md`, `webook_instructions.txt`).
|
||||
`tracksolid_db_connection.md` documents the public `twala:5433` superuser
|
||||
connection pattern that the tracksolid audit is eliminating — update or remove
|
||||
once the SSH-tunnel workflow lands.
|
||||
- The serving layer is otherwise good: Caddyfile validated at build time,
|
||||
healthcheck endpoint, runtime `API_BASE` injection via `templates`, `no-store`
|
||||
on the shell and env.js.
|
||||
27
docs/260702_fix_plan.md
Normal file
27
docs/260702_fix_plan.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# FleetOps — Fix Plan (2026-07-02)
|
||||
|
||||
Companion to `260702_audit_report.md` and `260702_work_done.md`.
|
||||
|
||||
## Phase A — repo changes (implemented in this session)
|
||||
|
||||
| Step | Finding | Change |
|
||||
|---|---|---|
|
||||
| A1 | FO-SEC-01 | Hoist `escapeHtml` into the HELPERS section, add an `esc()` convenience (escapes or em-dashes), and route every API string in the logistics + fuel renderers and the three error banners through it. The tickets code already escaped — now the whole file is consistent. |
|
||||
| A2 | FO-SEC-02 | Add `integrity="sha384-…" crossorigin="anonymous"` to the three CDN assets (hashes computed from the exact pinned files; recompute command documented inline). |
|
||||
| A3 | FO-SEC-03 | Caddyfile: `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `frame-ancestors 'none'`, and a CSP restricting script/style/img/font/connect origins to self + the two pinned CDNs + CARTO + the two fleet APIs. `script-src` keeps `'unsafe-inline'` (the app is one inline script block); the CSP's value here is limiting external script origins and exfil targets. |
|
||||
| A4 | FO-BUG-01/02 | `loadLive()` checks `r.ok`, and skips while `document.hidden` or when the Tickets tab isn't active; switching back to Tickets (or the page becoming visible) triggers an immediate refresh so markers don't wait out the 15 s interval. |
|
||||
| A5 | FO-OPS-01 | Fallback API base flipped to the prod bridge (`fleetapi.rahamafresh.com`), matching the documented design. |
|
||||
|
||||
## Phase B — operational (needs operator decision)
|
||||
|
||||
| # | Item |
|
||||
|---|---|
|
||||
| B1 | Promotion pairing: when this branch is promoted to prod (Coolify deploy of fleetops.rahamafresh.com), redeploy the prod `dashboard_api` bridge first — it currently lacks the `/webhook/{inc,crq}-*` + `/analytics/fuel-fills` routes the SPA calls. |
|
||||
| B2 | Clean up untracked root scratch files; rewrite `tracksolid_db_connection.md` for the SSH-tunnel workflow once the DB port closes. |
|
||||
| B3 | Longer-term: consider vendoring Chart.js/MapLibre into `src/` (removes the CDN trust surface entirely and lets the CSP drop jsdelivr/unpkg). |
|
||||
|
||||
## Verification
|
||||
- Extracted inline script passes `node --check`.
|
||||
- Caddyfile passes `caddy validate` (run inside the deployed fleetops image).
|
||||
- Manual: staging deploy → open all four tabs, confirm charts/map/popups render,
|
||||
check DevTools console for CSP violations before promoting.
|
||||
32
docs/260702_work_done.md
Normal file
32
docs/260702_work_done.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# FleetOps — Work Done (2026-07-02)
|
||||
|
||||
Execution log for `260702_fix_plan.md` Phase A. **Local changes only — nothing
|
||||
committed, pushed, or deployed.**
|
||||
|
||||
## Changes
|
||||
|
||||
| Finding | Files | What changed |
|
||||
|---|---|---|
|
||||
| FO-SEC-01 | `src/index.html` | `escapeHtml` moved to HELPERS with a new `esc()` shorthand; all previously-unescaped API strings now escape: `renderVehicles` (vehicle_number, cost_centre, assigned_city), `renderDrivers` (driver_name, assigned_city), `renderFuel` banner notes, `renderFuelVehicles`, `renderFuelDepartments`, `renderFuelRecent` (plate, department, driver, fuel_type), and the three catch-block error banners (`e.message`). WhatsApp-sourced Fuel Log strings can no longer inject markup. |
|
||||
| FO-SEC-02 | `src/index.html` | SRI `integrity` + `crossorigin="anonymous"` on Chart.js 4.4.1, maplibre-gl 4.7.1 JS and CSS (sha384 computed from the exact CDN payloads; recompute one-liner documented inline for future bumps). |
|
||||
| FO-SEC-03 | `Caddyfile` | Security header block: `nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, CSP (self + pinned CDNs + CARTO basemap + the two fleet APIs; `worker-src blob:` for MapLibre; `frame-ancestors 'none'`), `-Server`. `script-src` retains `'unsafe-inline'` because the app is a single inline script block. |
|
||||
| FO-BUG-01 | `src/index.html` | `loadLive()` now throws on non-OK responses instead of parsing error bodies. |
|
||||
| FO-BUG-02 | `src/index.html` | The 15 s live-position poll skips while `document.hidden` or when the active tab isn't Tickets; switching back to Tickets or re-focusing the page triggers an immediate refresh. Cuts thousands of pointless API calls/day per idle viewer. |
|
||||
| FO-OPS-01 | `src/index.html` | Missing-`API_BASE` fallback now targets the prod bridge (`fleetapi.rahamafresh.com`) per the documented design (was the staging bridge). |
|
||||
|
||||
## Verification
|
||||
- Inline app script extracted and passed `node --check` (no syntax errors).
|
||||
- Modified Caddyfile passed `caddy validate` inside the deployed fleetops image on
|
||||
twala ("Valid configuration").
|
||||
- 37 `esc(`/`escapeHtml(` call sites after the change (was 21, tickets-only).
|
||||
|
||||
## NOT done — operational
|
||||
1. Deploy flows through the Forgejo → Coolify webhook on push (staging first).
|
||||
Before **prod** promotion, redeploy the prod `dashboard_api` bridge — it lacks
|
||||
the INC/CRQ/fuel-fills routes this SPA version calls.
|
||||
2. After the staging deploy, click through all four tabs with DevTools open to
|
||||
confirm zero CSP violations before promoting.
|
||||
3. Root scratch files (`marker-preview.html`, `tracksolid_db_connection.md`,
|
||||
`webook_instructions.txt`) left untouched — decide keep/commit/delete;
|
||||
`tracksolid_db_connection.md` should be rewritten for the SSH-tunnel workflow
|
||||
once the public DB port closes.
|
||||
|
|
@ -20,10 +20,18 @@
|
|||
-->
|
||||
<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>
|
||||
<!-- SRI pins (FO-SEC-02): a compromised/MITM'd CDN can't inject script. When
|
||||
bumping a CDN version, recompute: curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"
|
||||
integrity="sha384-9nhczxUqK87bcKHh20fSQcTGD4qq5GhayNYSYWqwBkINBhOfQLg/P5HG5lF1urn4"
|
||||
crossorigin="anonymous"></script>
|
||||
<!-- MapLibre GL — Tickets tab map (FleetNow-style live map + INC/CRQ layers). -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"
|
||||
integrity="sha384-MinO0mNliZ3vwppuPOUnGa+iq619pfMhLVUXfC4LHwSCvF9H+6P/KO4Q7qBOYV5V"
|
||||
crossorigin="anonymous" />
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"
|
||||
integrity="sha384-SYKAG6cglRMN0RVvhNeBY0r3FYKNOJtznwA0v7B5Vp9tr31xAHsZC0DqkQ/pZDmj"
|
||||
crossorigin="anonymous"></script>
|
||||
<style>
|
||||
:root {
|
||||
/* Shared with FleetNow (warm dark ops palette) */
|
||||
|
|
@ -464,7 +472,8 @@
|
|||
// ============================================================================
|
||||
const API_BASE = (window.FLEETOPS_API_BASE && /^https?:\/\//.test(window.FLEETOPS_API_BASE))
|
||||
? window.FLEETOPS_API_BASE.replace(/\/$/, '')
|
||||
: 'https://fleetapi.fivetitude.com'; // staging default
|
||||
: 'https://fleetapi.rahamafresh.com'; // prod fallback — a prod pod missing API_BASE
|
||||
// must not silently lean on the staging bridge
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
|
|
@ -472,6 +481,12 @@ const API_BASE = (window.FLEETOPS_API_BASE && /^https?:\/\//.test(window.FLEETOP
|
|||
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');
|
||||
// FO-SEC-01: every API string rendered via innerHTML goes through this. Fleet
|
||||
// names come from the Tracksolid registry and fuel rows from WhatsApp messages —
|
||||
// both are effectively user input, so unescaped interpolation is stored XSS.
|
||||
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const esc = (v, dash = '—') => (v == null ? dash : escapeHtml(v));
|
||||
// Which tab owns the shared header KPI strip right now — guards late async
|
||||
// loaders (e.g. boot loadAll finishing after the user switched tabs) from
|
||||
// clobbering another tab's header.
|
||||
|
|
@ -606,9 +621,9 @@ function renderVehicles(rows) {
|
|||
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 class="plate">${esc(r.vehicle_number)}</td>
|
||||
<td class="dim">${esc(r.cost_centre)}</td>
|
||||
<td class="dim">${esc(r.assigned_city)}</td>
|
||||
<td>${intg(r.trips)}</td>
|
||||
<td>${num(r.total_km, 1)}</td>
|
||||
<td>${num(r.driving_hours, 1)}</td>
|
||||
|
|
@ -632,8 +647,8 @@ function renderDrivers(d) {
|
|||
}
|
||||
const body = rows.map(r => `
|
||||
<tr>
|
||||
<td class="plate">${r.driver_name ?? '—'}</td>
|
||||
<td class="dim">${r.assigned_city ?? '—'}</td>
|
||||
<td class="plate">${esc(r.driver_name)}</td>
|
||||
<td class="dim">${esc(r.assigned_city)}</td>
|
||||
<td>${intg(r.active_days)}</td>
|
||||
<td>${num(r.total_km, 0)}</td>
|
||||
<td>${intg(r.trips)}</td>
|
||||
|
|
@ -656,7 +671,7 @@ function renderFuel(d) {
|
|||
$('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>`;
|
||||
html += `<div class="banner">No fuel figures yet.<ul>${(ds.notes || []).map(n => `<li>${escapeHtml(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);
|
||||
|
|
@ -695,7 +710,7 @@ async function loadAll() {
|
|||
} 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>`);
|
||||
el.innerHTML = `<div class="banner error">${escapeHtml(e.message || 'Failed to load. Is the API reachable?')}</div>`);
|
||||
} finally {
|
||||
$('main').classList.remove('loading');
|
||||
}
|
||||
|
|
@ -771,9 +786,9 @@ function renderFuelVehicles(rows) {
|
|||
if (!rows.length) { $('fv-wrap').innerHTML = '<div class="empty">No fills in range.</div>'; return; }
|
||||
const body = rows.map(r => `
|
||||
<tr>
|
||||
<td class="plate">${r.vehicle_number ?? r.plate ?? '—'}</td>
|
||||
<td class="dim">${r.cost_centre ?? '—'}</td>
|
||||
<td class="dim">${r.assigned_city ?? '—'}</td>
|
||||
<td class="plate">${esc(r.vehicle_number ?? r.plate)}</td>
|
||||
<td class="dim">${esc(r.cost_centre)}</td>
|
||||
<td class="dim">${esc(r.assigned_city)}</td>
|
||||
<td>${num(r.litres, 1)}</td>
|
||||
<td>${intg(r.spend_kes)}</td>
|
||||
<td>${intg(r.fills)}</td>
|
||||
|
|
@ -791,7 +806,7 @@ function renderFuelDepartments(rows) {
|
|||
if (!rows.length) { $('fd-wrap').innerHTML = '<div class="empty">No data.</div>'; return; }
|
||||
const body = rows.map(r => `
|
||||
<tr>
|
||||
<td>${r.department ?? '—'}</td>
|
||||
<td>${esc(r.department)}</td>
|
||||
<td>${num(r.litres, 0)}</td>
|
||||
<td>${intg(r.spend_kes)}</td>
|
||||
<td>${intg(r.fills)}</td>
|
||||
|
|
@ -813,12 +828,12 @@ function renderFuelRecent(rows) {
|
|||
const body = rows.map(r => `
|
||||
<tr>
|
||||
<td class="dim">${dt(r.record_datetime)}</td>
|
||||
<td class="plate">${r.vehicle_number ?? r.plate ?? '—'}</td>
|
||||
<td class="dim">${r.department ?? '—'}</td>
|
||||
<td class="dim">${r.driver ?? '—'}</td>
|
||||
<td class="plate">${esc(r.vehicle_number ?? r.plate)}</td>
|
||||
<td class="dim">${esc(r.department)}</td>
|
||||
<td class="dim">${esc(r.driver)}</td>
|
||||
<td>${num(r.liters, 1)}</td>
|
||||
<td>${intg(r.amount)}</td>
|
||||
<td class="dim">${r.fuel_type ?? '—'}</td>
|
||||
<td class="dim">${esc(r.fuel_type)}</td>
|
||||
<td class="dim">${r.odometer != null ? intg(r.odometer) : '—'}</td>
|
||||
</tr>`).join('');
|
||||
$('fr-wrap').innerHTML = `<table>
|
||||
|
|
@ -844,7 +859,7 @@ async function loadFuel() {
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
$('fuel-main').querySelectorAll('.tbl-scroll').forEach(el =>
|
||||
el.innerHTML = `<div class="banner error">${e.message || 'Failed to load. Is the API reachable?'}</div>`);
|
||||
el.innerHTML = `<div class="banner error">${escapeHtml(e.message || 'Failed to load. Is the API reachable?')}</div>`);
|
||||
} finally {
|
||||
$('fuel-main').classList.remove('loading');
|
||||
}
|
||||
|
|
@ -875,6 +890,7 @@ function switchTab(name) {
|
|||
$('kpis').innerHTML = ''; // INC metrics live in the INC overview card, not the header
|
||||
loadInc(); // (re)load INC data first — independent of the basemap
|
||||
initIncMap(); // then build the map (lazy) / just resize if built
|
||||
if (tkMap) loadLive(); // instant marker refresh — the poll pauses off-tab
|
||||
} else if (name === 'fuel') {
|
||||
if (lastFuelTotals) renderFuelKpis(lastFuelTotals);
|
||||
if (!fuelLoaded) loadFuel(); // lazy — first open
|
||||
|
|
@ -950,8 +966,7 @@ const COST_CENTRE_COLORS = {
|
|||
'planning': '#06b6d4', 'deliveries': '#84cc16', 'qehs': '#14b8a6', 'airtel': '#ef4444',
|
||||
};
|
||||
const CC_PALETTE = ['#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981'];
|
||||
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// escapeHtml/esc live in HELPERS (top of script) — used by every renderer.
|
||||
function ccColor(cc) {
|
||||
if (!cc) return '#9ca3af';
|
||||
const key = String(cc).trim().toLowerCase();
|
||||
|
|
@ -1297,15 +1312,19 @@ async function loadInc() {
|
|||
buildIncLayers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
$('tk-metrics').innerHTML = `<div class="banner error">${e.message || `Failed to load the ${DS.toUpperCase()} dashboard. Is the API reachable?`}</div>`;
|
||||
$('tk-metrics').innerHTML = `<div class="banner error">${escapeHtml(e.message || `Failed to load the ${DS.toUpperCase()} dashboard. Is the API reachable?`)}</div>`;
|
||||
} finally {
|
||||
$('tk-main').classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLive() {
|
||||
// Pause the 15s poll while the page is hidden or another tab owns the screen —
|
||||
// no point hammering the API for markers nobody can see.
|
||||
if (document.hidden || activeTab() !== 'tickets') return;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/webhook/live-positions`, { headers: { 'Accept': 'application/json' } });
|
||||
if (!r.ok) throw new Error(`live-positions → HTTP ${r.status}`);
|
||||
const j = await r.json();
|
||||
const feats = (j.geojson && j.geojson.features) || [];
|
||||
const seen = new Set();
|
||||
|
|
@ -1414,6 +1433,11 @@ function buildIncLayers() {
|
|||
}));
|
||||
}
|
||||
|
||||
// Catch up as soon as the page becomes visible again (poll skips while hidden).
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && activeTab() === 'tickets' && tkMap) loadLive();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BOOT
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue