maplocation / static /js /dashboard.js
sarveshpatel's picture
Upload 29 files
dbd02c5 verified
Raw
History Blame Contribute Delete
35.9 kB
/* Live GPS dashboard — reactive Preact app (no build step).
*
* Preact + htm load as ES modules from a CDN. A tiny external store drives
* reactivity: WebSocket / poll updates write to stores, components that read a
* store re-render, and Preact diffs the result — only changed DOM is touched
* (no innerHTML wipes → no flicker, no lag, no lost scroll). Leaflet maps are
* managed imperatively in the `maps` controller, outside Preact's render. */
import { h, render, Fragment } from "https://esm.sh/preact@10.25.4";
import { useState, useEffect, useRef } from "https://esm.sh/preact@10.25.4/hooks";
import htmModule from "https://esm.sh/htm@3.1.1";
const html = htmModule.bind(h);
/* ============ tiny reactive store ============ */
function createStore(initial) {
let value = initial;
const subs = new Set();
return {
get: () => value,
set: (next) => {
value = typeof next === "function" ? next(value) : next;
subs.forEach((fn) => fn());
},
subscribe: (fn) => {
subs.add(fn);
return () => subs.delete(fn);
},
};
}
function useStore(store) {
const [, force] = useState(0);
useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]);
return store.get();
}
/* ============ stores ============ */
const devices$ = createStore([]);
const alerts$ = createStore([]);
const geofences$ = createStore([]);
const accuracy$ = createStore([]); // [{t,v}]
const connection$ = createStore("connecting");
const updated$ = createStore("");
const section$ = createStore("dashboard");
const theme$ = createStore(localStorage.getItem("gps-theme") || "light");
const search$ = createStore("");
/* ============ constants + helpers ============ */
const ROUTE_COLOR = "#6750A4"; // thin brand-purple route line
const DONUT_C = 2 * Math.PI * 50;
const PALETTE = ["#6750A4", "#386A20", "#B3261E", "#00658F", "#7D5260", "#8C4A00"];
function esc(s) {
return String(s == null ? "" : s);
}
function timeAgo(iso) {
const t = new Date(iso).getTime();
if (isNaN(t)) return "—";
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 60) return Math.floor(s) + "s ago";
if (s < 3600) return Math.floor(s / 60) + "m ago";
if (s < 86400) return Math.floor(s / 3600) + "h ago";
return Math.floor(s / 86400) + "d ago";
}
function colorFor(id) {
let hsh = 0;
for (let i = 0; i < id.length; i++) hsh = (hsh * 31 + id.charCodeAt(i)) | 0;
return PALETTE[Math.abs(hsh) % PALETTE.length];
}
function humanType(t) {
return (
{ low_battery: "Low Battery", offline: "No Signal", gps_off: "GPS Disabled" }[t] ||
t.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())
);
}
function alertGlyph(type) {
return type === "low_battery" ? "\u{1F50B}" : type === "offline" ? "\u{1F4F6}" : "⚠";
}
function alertColor(sev) {
return sev === "high" ? "var(--sev-high)" : sev === "medium" ? "var(--sev-medium)" : "var(--primary)";
}
function motionPill(d) {
if (d.status === "inactive") return html`<span class="pill pill-inactive">Inactive</span>`;
const moving = d.last_location && (d.last_location.speed || 0) >= 1;
return moving
? html`<span class="pill pill-moving">Moving</span>`
: html`<span class="pill pill-stationary">Stationary</span>`;
}
/* ============ OSRM road snapping ============ */
async function snapToRoads(latlngs) {
if (!latlngs || latlngs.length < 2) return latlngs || [];
const pts = latlngs.slice(-95);
const coords = pts.map((p) => p[1].toFixed(6) + "," + p[0].toFixed(6)).join(";");
const url =
"https://router.project-osrm.org/match/v1/driving/" +
coords +
"?geometries=geojson&overview=full&tidy=true";
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 6000);
try {
const r = await fetch(url, { signal: ctrl.signal });
if (!r.ok) return pts;
const data = await r.json();
if (!data.matchings || !data.matchings.length) return pts;
const out = [];
data.matchings.forEach((m) => {
((m.geometry && m.geometry.coordinates) || []).forEach((c) => out.push([c[1], c[0]]));
});
return out.length ? out : pts;
} catch (e) {
return pts; // fall back to a straight (still thin purple) line
} finally {
clearTimeout(timer);
}
}
/* ============ Leaflet map controller (imperative) ============ */
const maps = {
dash: null,
history: null,
geo: null,
markers: {},
routes: {},
histLayer: null,
geoLayer: null,
geoPending: null,
geoPendingMarker: null,
ready: false,
tileUrl() {
const dark = document.documentElement.getAttribute("data-theme") === "dark";
return (
"https://{s}.basemaps.cartocdn.com/" +
(dark ? "dark_all" : "light_all") +
"/{z}/{x}/{y}{r}.png"
);
},
tiles(m) {
if (!m) return;
m.eachLayer((l) => {
if (l instanceof L.TileLayer) m.removeLayer(l);
});
L.tileLayer(this.tileUrl(), {
maxZoom: 19,
subdomains: "abcd",
attribution: "&copy; OpenStreetMap, &copy; CARTO",
}).addTo(m);
},
refreshTiles() {
[this.dash, this.history, this.geo].forEach((m) => this.tiles(m));
},
init() {
if (this.ready) return;
const center = [20.5937, 78.9629];
this.dash = L.map("map", { zoomControl: true }).setView(center, 5);
this.history = L.map("map-history").setView(center, 5);
this.geo = L.map("map-geofences").setView(center, 5);
[this.dash, this.history, this.geo].forEach((m) => this.tiles(m));
this.geo.on("click", (e) => {
this.geoPending = e.latlng;
if (this.geoPendingMarker) this.geo.removeLayer(this.geoPendingMarker);
this.geoPendingMarker = L.marker(e.latlng).addTo(this.geo);
});
// A ResizeObserver makes a map render correctly the moment its (previously
// hidden, 0-sized) section becomes visible — the robust fix for blank maps.
[
["map", this.dash],
["map-history", this.history],
["map-geofences", this.geo],
].forEach(([id, m]) => {
const el = document.getElementById(id);
if (el && window.ResizeObserver) {
new ResizeObserver(() => m.invalidateSize()).observe(el);
}
});
this.ready = true;
},
onSection(name) {
setTimeout(() => {
if (name === "dashboard" && this.dash) this.dash.invalidateSize();
if (name === "history" && this.history) this.history.invalidateSize();
if (name === "geofences" && this.geo) this.geo.invalidateSize();
}, 60);
},
applyLocation(msg) {
if (!this.dash || typeof msg.lat !== "number") return;
const id = msg.device_id;
const ll = [msg.lat, msg.lng];
let m = this.markers[id];
if (!m) {
this.markers[id] = L.circleMarker(ll, {
radius: 8,
color: "#fff",
weight: 3,
fillColor: colorFor(id),
fillOpacity: 1,
})
.addTo(this.dash)
.bindPopup(esc(id));
const pts = Object.keys(this.markers).map((k) => this.markers[k].getLatLng());
if (pts.length === 1) this.dash.setView(ll, 16);
else this.dash.fitBounds(L.latLngBounds(pts).pad(0.25));
} else {
m.setLatLng(ll);
m.bringToFront();
}
const r = this.routes[id] || (this.routes[id] = { pts: [], line: null, lastMatch: 0, busy: false });
const last = r.pts[r.pts.length - 1];
if (!last || last[0] !== ll[0] || last[1] !== ll[1]) {
r.pts.push(ll);
if (r.pts.length > 400) r.pts.shift();
}
if (Date.now() - r.lastMatch > 4500) this.drawRoute(id);
},
drawRoute(id) {
const r = this.routes[id];
if (!r || r.busy || r.pts.length < 2) return;
r.busy = true;
r.lastMatch = Date.now();
snapToRoads(r.pts).then((snapped) => {
const rr = this.routes[id];
if (!rr || !this.dash) return;
rr.busy = false;
if (rr.line) this.dash.removeLayer(rr.line);
rr.line = L.polyline(snapped, {
color: ROUTE_COLOR,
weight: 3,
opacity: 0.95,
lineJoin: "round",
lineCap: "round",
}).addTo(this.dash);
if (this.markers[id]) this.markers[id].bringToFront();
});
},
seedRoutes(devices) {
devices.forEach((d) => {
if (this.routes[d.id]) return;
fetch("/api/devices/" + encodeURIComponent(d.id) + "/track?limit=300")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((data) => {
const pts = (data.points || []).map((p) => [p.lat, p.lng]);
if (pts.length < 2) return;
this.routes[d.id] = { pts, line: null, lastMatch: 0, busy: false };
this.drawRoute(d.id);
})
.catch(() => {});
});
},
async loadHistory(id) {
const r = await fetch("/api/devices/" + encodeURIComponent(id) + "/track?limit=5000")
.then((x) => (x.ok ? x.json() : Promise.reject()))
.catch(() => ({ points: [] }));
const raw = (r.points || []).map((p) => [p.lat, p.lng]);
let dist = 0;
for (let i = 1; i < raw.length; i++) dist += haversine(raw[i - 1], raw[i]);
if (this.histLayer) this.history.removeLayer(this.histLayer);
this.histLayer = null;
if (raw.length >= 2) {
const snapped = await snapToRoads(raw);
this.histLayer = L.polyline(snapped, {
color: ROUTE_COLOR,
weight: 3,
opacity: 0.95,
lineJoin: "round",
}).addTo(this.history);
this.history.fitBounds(this.histLayer.getBounds().pad(0.2));
}
return { count: raw.length, distanceKm: dist / 1000 };
},
drawGeofences(list) {
if (!this.geo) return;
if (this.geoLayer) this.geo.removeLayer(this.geoLayer);
this.geoLayer = L.layerGroup().addTo(this.geo);
(list || []).forEach((g) => {
L.circle([g.lat, g.lng], { radius: g.radius_m, color: ROUTE_COLOR, fillOpacity: 0.12 })
.addTo(this.geoLayer)
.bindPopup(esc(g.name));
});
},
clearGeoPending() {
if (this.geoPendingMarker && this.geo) this.geo.removeLayer(this.geoPendingMarker);
this.geoPendingMarker = null;
this.geoPending = null;
},
};
function haversine(a, b) {
const R = 6371000;
const rad = Math.PI / 180;
const dLat = (b[0] - a[0]) * rad;
const dLng = (b[1] - a[1]) * rad;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
/* ============ data layer ============ */
function refreshData() {
fetch("/api/devices")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((list) => {
devices$.set(list || []);
maps.seedRoutes(list || []);
})
.catch(() => {});
fetch("/api/alerts")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((list) => alerts$.set(list || []))
.catch(() => {});
}
// Debounced refresh — called when a brand-new device appears over WebSocket,
// so the canonical DTO fields (status, effective_interval, …) fill in within
// half a second. A burst of new devices triggers only one extra fetch.
let _refreshTimer = null;
function scheduleSoonRefresh() {
if (_refreshTimer) return;
_refreshTimer = setTimeout(() => {
_refreshTimer = null;
refreshData();
}, 500);
}
function connectWebSocket() {
let delay = 1000;
function open() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
connection$.set("connecting");
const ws = new WebSocket(proto + "//" + location.host + "/ws/dashboard");
ws.onopen = () => {
delay = 1000;
connection$.set("connected");
};
ws.onmessage = (ev) => {
let msg;
try {
msg = JSON.parse(ev.data);
} catch (e) {
return;
}
if (msg.type === "snapshot") (msg.devices || []).forEach(applyLocation);
else if (msg.type === "location") applyLocation(msg);
updated$.set("Last update " + new Date().toLocaleTimeString());
};
ws.onerror = () => ws.close();
ws.onclose = () => {
connection$.set("disconnected");
setTimeout(open, delay);
delay = Math.min(delay * 2, 30000);
};
}
open();
}
function applyLocation(msg) {
if (typeof msg.lat !== "number") return;
if (typeof msg.accuracy === "number") {
accuracy$.set((a) => {
const next = a.concat({ t: msg.timestamp, v: msg.accuracy });
return next.length > 6 ? next.slice(-6) : next;
});
}
maps.applyLocation(msg);
// Merge the live fix into the device list so cards feel live too — including
// *new* devices (multi-user), which were previously dropped here and only
// showed up after the next 15-second poll.
devices$.set((list) => {
let found = false;
const next = list.map((d) => {
if (d.id !== msg.device_id) return d;
found = true;
return {
...d,
last_seen_at: msg.received_at || msg.timestamp,
last_location: {
lat: msg.lat,
lng: msg.lng,
accuracy: msg.accuracy,
speed: msg.speed,
bearing: msg.bearing,
timestamp: msg.timestamp,
},
};
});
if (found) return next;
// First time we see this device — show it live, then refresh to enrich.
scheduleSoonRefresh();
return [
{
id: msg.device_id,
name: msg.device_name || "",
status: "online",
battery: msg.battery,
effective_interval_sec: 120,
first_seen_at: msg.received_at || msg.timestamp,
last_seen_at: msg.received_at || msg.timestamp,
last_location: {
lat: msg.lat,
lng: msg.lng,
accuracy: msg.accuracy,
speed: msg.speed,
bearing: msg.bearing,
timestamp: msg.timestamp,
},
},
...list,
];
});
}
/* ============ components ============ */
function Sidebar() {
const section = useStore(section$);
const nav = [
["dashboard", "Dashboard", "☉"],
["devices", "Devices", "◫"],
["history", "History", "↻"],
["geofences", "Geofences", "◎"],
["alerts", "Alerts", "⚠"],
["reports", "Reports", "☷"],
["settings", "Settings", "⚙"],
];
return html`
<aside class="sidebar reveal" style="--i:0">
<div class="brand">
<span class="brand-mark">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z" />
</svg>
</span>
<span class="brand-name">Live GPS Tracker</span>
</div>
<nav class="nav">
${nav.map(
([id, label, ico]) => html`
<button
class=${"nav-item" + (section === id ? " active" : "")}
onClick=${() => section$.set(id)}
>
<span class="nav-ico">${ico}</span> ${label}
</button>
`
)}
</nav>
<div class="sidebar-foot">
<div class="m3-card status-card">
<div class="status-row">
<span class="dot dot-online"></span>
<span class="muted-sm">System status</span>
</div>
<div class="status-title">All systems operational</div>
</div>
<div class="m3-card user-card">
<div class="avatar">A</div>
<div>
<div class="user-name">Admin User</div>
<div class="muted-sm">admin@example.com</div>
</div>
</div>
</div>
</aside>
`;
}
function Topbar() {
const theme = useStore(theme$);
const alerts = useStore(alerts$);
const search = useStore(search$);
const unacked = alerts.filter((a) => !a.acknowledged).length;
return html`
<header class="topbar reveal" style="--i:1">
<div class="search">
<span class="search-ico">\u{1F50D}</span>
<input
type="text"
placeholder="Search devices, places…"
value=${search}
onInput=${(e) => search$.set(e.target.value)}
/>
<span class="kbd">⌘ K</span>
</div>
<div class="topbar-actions">
<button
class="icon-btn"
title="Toggle theme"
onClick=${() => theme$.set(theme === "dark" ? "light" : "dark")}
>
${theme === "dark" ? "☾" : "☀"}
</button>
<button class="icon-btn" title="Alerts" onClick=${() => section$.set("alerts")}>
\u{1F514}${unacked > 0 ? html`<span class="bell-dot"></span>` : null}
</button>
<div class="avatar avatar-sm">A</div>
</div>
</header>
`;
}
function Section({ id, pad, children }) {
const section = useStore(section$);
const cls = "section" + (section === id ? " active" : "");
return html`<section class=${cls} id=${"section-" + id}>
${pad ? html`<div class="section-pad">${children}</div>` : children}
</section>`;
}
/* ---- Dashboard ---- */
function Dashboard() {
const devices = useStore(devices$);
const alerts = useStore(alerts$);
const accuracy = useStore(accuracy$);
const [expanded, setExpanded] = useState(false);
const online = devices.filter((d) => d.status === "online").length;
const offline = devices.filter((d) => d.status === "offline").length;
const inactive = devices.filter((d) => d.status === "inactive").length;
const frac = devices.length ? online / devices.length : 0;
const recent = devices
.slice()
.sort((a, b) => new Date(b.last_seen_at) - new Date(a.last_seen_at))
.slice(0, 3);
const latestAcc = accuracy.length ? accuracy[accuracy.length - 1].v : null;
const accMax = Math.max(1, ...accuracy.map((a) => a.v));
function toggleExpand() {
setExpanded(!expanded);
setTimeout(() => maps.dash && maps.dash.invalidateSize(), 260);
}
return html`
<div class="dash-grid">
<div class=${"m3-card map-card reveal" + (expanded ? " expanded" : "")} id="map-card" style="--i:2">
<div id="map"></div>
<button class="map-expand" title="Expand / collapse map" onClick=${toggleExpand}>
${expanded ? "✕" : "⛶"}
</button>
</div>
<div class="m3-card stat-card reveal" style="--i:3">
<div class="card-head">
<h3>Live Status</h3>
<span class="muted-sm"><span class="dot dot-online"></span> ${online} Online</span>
</div>
<div class="donut-wrap">
<svg class="donut" viewBox="0 0 120 120">
<circle class="donut-track" cx="60" cy="60" r="50"></circle>
<circle
class="donut-value"
cx="60"
cy="60"
r="50"
stroke-dasharray=${frac * DONUT_C + " " + DONUT_C}
></circle>
</svg>
<div class="donut-center">
<div class="donut-num">${devices.length}</div>
<div class="muted-sm">Total Devices</div>
</div>
</div>
<div class="legend">
<span><span class="dot dot-online"></span> <b>${online}</b> Online</span>
<span><span class="dot dot-offline"></span> <b>${offline}</b> Offline</span>
<span><span class="dot dot-inactive"></span> <b>${inactive}</b> Inactive</span>
</div>
</div>
<div class="m3-card stat-card reveal" style="--i:4">
<div class="card-head">
<h3>Recent Devices</h3>
<a class="link" onClick=${() => section$.set("devices")}>View all</a>
</div>
<div class="recent-list">
${recent.length === 0
? html`<div class="muted-sm">No devices yet.</div>`
: recent.map((d) => {
const loc = d.last_location;
return html`
<div class="recent-row" key=${d.id}>
<span
class=${"dot dot-" + d.status}
style="margin-top:4px"
></span>
<div style="flex:1;min-width:0">
<div class="rr-id">${d.id.slice(0, 18)}</div>
<div class="rr-meta">
${loc
? "Lat " + loc.lat.toFixed(5) + ", Lng " + loc.lng.toFixed(5)
: "No data"}
</div>
<div style="margin-top:4px">
${motionPill(d)}
${" "}
<span class="rr-meta"
>${loc && loc.accuracy != null
? "Accuracy ±" + Math.round(loc.accuracy) + " m"
: ""}</span
>
</div>
</div>
<span class="rr-meta">${timeAgo(d.last_seen_at)}</span>
</div>
`;
})}
</div>
</div>
<div class="m3-card stat-card reveal" style="--i:5">
<div class="card-head"><h3>Quick Actions</h3></div>
<button class="quick" onClick=${() => section$.set("devices")}>
<span class="quick-ico">+</span>
<span><b>Add Device</b><br /><span class="muted-sm">Register a new tracker</span></span>
</button>
<button class="quick" onClick=${() => section$.set("geofences")}>
<span class="quick-ico"></span>
<span><b>Create Geofence</b><br /><span class="muted-sm">Define safe zones</span></span>
</button>
<button class="quick" onClick=${() => section$.set("reports")}>
<span class="quick-ico"></span>
<span><b>Generate Report</b><br /><span class="muted-sm">Export tracking data</span></span>
</button>
</div>
<div class="m3-card stat-card reveal" style="--i:6">
<div class="card-head"><h3>Location Accuracy</h3></div>
<span class="chip chip-accuracy"
>${latestAcc != null && latestAcc <= 15 ? "High accuracy" : "Tracking"}</span
>
<div class="acc-big">${latestAcc != null ? "±" + Math.round(latestAcc) + " m" : "—"}</div>
<div class="muted-sm">Current accuracy</div>
<div class="bars">
${accuracy.map((a, i) => {
const hgt = Math.max(8, (a.v / accMax) * 58);
return html`
<div class="bar" key=${i}>
<div
class=${"bar-fill" + (i === accuracy.length - 1 ? " peak" : "")}
style=${"height:" + hgt + "px"}
></div>
<span class="bar-label">±${Math.round(a.v)}</span>
</div>
`;
})}
</div>
</div>
<div class="m3-card alerts-card reveal" style="--i:7">
<div class="card-head">
<h3>Recent Alerts</h3>
<a class="link" onClick=${() => section$.set("alerts")}>View all</a>
</div>
<table class="data-table">
<thead>
<tr><th>Type</th><th>Device</th><th>Message</th><th>Time</th></tr>
</thead>
<tbody>
${alerts.length === 0
? html`<tr><td colspan="4" class="muted-sm">No alerts.</td></tr>`
: alerts.slice(0, 6).map(
(a) => html`
<tr key=${a.id}>
<td>
<div class="alert-type">
<span
class="alert-ico"
style=${"background:" + alertColor(a.severity) + "22;color:" + alertColor(a.severity)}
>${alertGlyph(a.type)}</span
>
${humanType(a.type)}
</div>
</td>
<td>${a.device_id.slice(0, 16)}</td>
<td>${a.message}</td>
<td>${timeAgo(a.created_at)}</td>
</tr>
`
)}
</tbody>
</table>
</div>
</div>
`;
}
/* ---- Devices ---- */
function Devices() {
const devices = useStore(devices$);
const q = useStore(search$).toLowerCase();
const list = q ? devices.filter((d) => d.id.toLowerCase().includes(q)) : devices;
return html`
<h2>Devices</h2>
<div class="m3-card list-card reveal" style="--i:0">
<table class="data-table">
<thead>
<tr>
<th>Status</th><th>Device</th><th>Position</th><th>Battery</th>
<th>Accuracy</th><th>Interval</th><th>Last seen</th>
</tr>
</thead>
<tbody>
${list.length === 0
? html`<tr><td colspan="7" class="empty">No devices yet.</td></tr>`
: list.map((d) => {
const loc = d.last_location;
return html`
<tr key=${d.id}>
<td><span class=${"pill pill-" + d.status}>${d.status}</span></td>
<td>${d.id}</td>
<td>${loc ? loc.lat.toFixed(5) + ", " + loc.lng.toFixed(5) : "—"}</td>
<td>${d.battery != null ? d.battery + "%" : "—"}</td>
<td>${loc && loc.accuracy != null ? "±" + Math.round(loc.accuracy) + " m" : "—"}</td>
<td>every ${d.effective_interval_sec}s</td>
<td>${timeAgo(d.last_seen_at)}</td>
</tr>
`;
})}
</tbody>
</table>
</div>
`;
}
/* ---- Alerts ---- */
function Alerts() {
const alerts = useStore(alerts$);
function ack(id) {
fetch("/api/alerts/" + id + "/ack", { method: "POST" }).then(refreshData);
}
return html`
<h2>Alerts</h2>
<div class="m3-card list-card reveal" style="--i:0">
<table class="data-table">
<thead>
<tr><th>Type</th><th>Severity</th><th>Device</th><th>Message</th><th>Time</th><th></th></tr>
</thead>
<tbody>
${alerts.length === 0
? html`<tr><td colspan="6" class="empty">No alerts.</td></tr>`
: alerts.map(
(a) => html`
<tr key=${a.id}>
<td>
<div class="alert-type">
<span
class="alert-ico"
style=${"background:" + alertColor(a.severity) + "22;color:" + alertColor(a.severity)}
>${alertGlyph(a.type)}</span
>
${humanType(a.type)}
</div>
</td>
<td><span class=${"pill pill-" + (a.severity || "low")}>${a.severity}</span></td>
<td>${a.device_id.slice(0, 18)}</td>
<td>${a.message}</td>
<td>${timeAgo(a.created_at)}</td>
<td>
${a.acknowledged
? html`<span class="muted-sm">Acknowledged</span>`
: html`<button class="btn btn-ghost" onClick=${() => ack(a.id)}>Acknowledge</button>`}
</td>
</tr>
`
)}
</tbody>
</table>
</div>
`;
}
/* ---- History ---- */
function History() {
const devices = useStore(devices$);
const [deviceId, setDeviceId] = useState("");
const [summary, setSummary] = useState(null);
useEffect(() => {
if (!deviceId && devices.length) setDeviceId(devices[0].id);
}, [devices]);
function load() {
const id = deviceId || (devices[0] && devices[0].id);
if (!id) return;
setSummary("loading");
maps.loadHistory(id).then((s) => setSummary(s));
}
useEffect(() => {
if (deviceId) load();
}, [deviceId]);
return html`
<h2>History</h2>
<div class="toolbar reveal" style="--i:0">
<select value=${deviceId} onChange=${(e) => setDeviceId(e.target.value)}>
${devices.map((d) => html`<option value=${d.id} key=${d.id}>${d.id}</option>`)}
</select>
<button class="btn btn-tonal" onClick=${load}>Load track</button>
</div>
<div id="map-history" class="reveal" style="--i:1"></div>
<div class="m3-card list-card reveal" style="--i:2">
${summary === "loading"
? html`<span class="muted-sm">Loading track…</span>`
: summary
? html`
<div class="kv"><span>Total points</span><b>${summary.count}</b></div>
<div class="kv"><span>Distance</span><b>${summary.distanceKm.toFixed(2)} km</b></div>
`
: html`<span class="muted-sm">Pick a device to load its track.</span>`}
</div>
`;
}
/* ---- Geofences ---- */
function Geofences() {
const geofences = useStore(geofences$);
const [name, setName] = useState("");
const [radius, setRadius] = useState("200");
useEffect(() => {
reloadGeofences();
}, []);
useEffect(() => {
maps.drawGeofences(geofences);
}, [geofences]);
function create() {
const rad = parseFloat(radius);
if (!name.trim() || !maps.geoPending || !(rad > 0)) {
alert("Enter a name, click the map for a centre, and set a radius.");
return;
}
fetch("/api/geofences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
lat: maps.geoPending.lat,
lng: maps.geoPending.lng,
radius_m: rad,
}),
}).then(() => {
setName("");
maps.clearGeoPending();
reloadGeofences();
});
}
function del(id) {
fetch("/api/geofences/" + id, { method: "DELETE" }).then(reloadGeofences);
}
return html`
<h2>Geofences</h2>
<p class="muted-sm reveal" style="--i:0;margin-bottom:10px">
Click the map to place a geofence centre.
</p>
<div id="map-geofences" class="reveal" style="--i:1"></div>
<div class="toolbar reveal" style="--i:2">
<input placeholder="Zone name" value=${name} onInput=${(e) => setName(e.target.value)} />
<input
type="number"
placeholder="Radius (m)"
style="width:140px"
value=${radius}
onInput=${(e) => setRadius(e.target.value)}
/>
<button class="btn btn-primary" onClick=${create}>Create geofence</button>
</div>
<div class="grid-cards reveal" style="--i:3">
${geofences.length === 0
? html`<div class="empty">No geofences yet.</div>`
: geofences.map(
(g) => html`
<div class="m3-card list-card" key=${g.id}>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>${g.name}</b>
<button class="btn btn-ghost" onClick=${() => del(g.id)}>Delete</button>
</div>
<div class="kv">
<span>Centre</span><span>${g.lat.toFixed(4)}, ${g.lng.toFixed(4)}</span>
</div>
<div class="kv"><span>Radius</span><span>${Math.round(g.radius_m)} m</span></div>
</div>
`
)}
</div>
`;
}
function reloadGeofences() {
fetch("/api/geofences")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((list) => geofences$.set(list || []))
.catch(() => {});
}
/* ---- Reports ---- */
function Reports() {
const devices = useStore(devices$);
const [deviceId, setDeviceId] = useState("");
useEffect(() => {
if (!deviceId && devices.length) setDeviceId(devices[0].id);
}, [devices]);
return html`
<h2>Reports</h2>
<div class="m3-card list-card reveal" style="--i:0">
<p class="muted-sm" style="margin-bottom:12px">
Export a device's full location history as CSV.
</p>
<div class="toolbar">
<select value=${deviceId} onChange=${(e) => setDeviceId(e.target.value)}>
${devices.map((d) => html`<option value=${d.id} key=${d.id}>${d.id}</option>`)}
</select>
<button
class="btn btn-primary"
onClick=${() => {
const id = deviceId || (devices[0] && devices[0].id);
if (id) window.open("/api/devices/" + encodeURIComponent(id) + "/export.csv", "_blank");
}}
>
Download CSV
</button>
</div>
</div>
`;
}
/* ---- Settings ---- */
function Settings() {
const theme = useStore(theme$);
const [intervalSec, setIntervalSec] = useState(null);
const [msg, setMsg] = useState("");
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((s) => setIntervalSec(String(s.default_capture_interval_sec)))
.catch(() => setIntervalSec("120"));
}, []);
function save() {
const v = parseInt(intervalSec, 10);
if (!(v >= 10)) {
setMsg("Interval must be ≥ 10.");
return;
}
fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_capture_interval_sec: v }),
}).then((r) => {
setMsg(r.ok ? "Saved." : "Save failed.");
refreshData();
});
}
return html`
<h2>Settings</h2>
<div class="m3-card list-card reveal" style="--i:0">
<div class="setting-row">
<div>
<b>Default capture interval</b><br />
<span class="muted-sm">How often devices report location (seconds).</span>
</div>
<input
type="number"
style="width:120px"
value=${intervalSec == null ? "" : intervalSec}
onInput=${(e) => setIntervalSec(e.target.value)}
/>
</div>
<div class="setting-row">
<div><b>Theme</b><br /><span class="muted-sm">Light or dark dashboard.</span></div>
<button
class="btn btn-ghost"
onClick=${() => theme$.set(theme === "dark" ? "light" : "dark")}
>
Toggle theme
</button>
</div>
<div style="margin-top:14px">
<button class="btn btn-primary" onClick=${save}>Save settings</button>
${" "}<span class="muted-sm">${msg}</span>
</div>
</div>
`;
}
/* ---- Status bar ---- */
function StatusBar() {
const conn = useStore(connection$);
const updated = useStore(updated$);
const text =
conn === "connected"
? "Live — receiving updates"
: conn === "connecting"
? "Connecting…"
: "Disconnected — retrying…";
return html`
<footer class="statusbar">
<span class="status-led" data-state=${conn}></span>
<span>${text}</span>
<span class="spacer"></span>
<span>${updated || "—"}</span>
</footer>
`;
}
/* ---- App root ---- */
function App() {
const section = useStore(section$);
const theme = useStore(theme$);
// Apply theme to <html> and refresh map tiles when it changes.
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("gps-theme", theme);
if (maps.ready) maps.refreshTiles();
}, [theme]);
// Create the Leaflet maps once, after the container divs are in the DOM.
useEffect(() => {
maps.init();
connectWebSocket();
refreshData();
const t = window.setInterval(refreshData, 15000);
return () => window.clearInterval(t);
}, []);
// Whenever the visible section changes, make sure its map is sized.
useEffect(() => {
maps.onSection(section);
}, [section]);
return html`
<div class="app">
<${Sidebar} />
<main class="main">
<${Topbar} />
<${Section} id="dashboard"><${Dashboard} /><//>
<${Section} id="devices" pad=${true}><${Devices} /><//>
<${Section} id="history" pad=${true}><${History} /><//>
<${Section} id="geofences" pad=${true}><${Geofences} /><//>
<${Section} id="alerts" pad=${true}><${Alerts} /><//>
<${Section} id="reports" pad=${true}><${Reports} /><//>
<${Section} id="settings" pad=${true}><${Settings} /><//>
<${StatusBar} />
</main>
</div>
`;
}
render(html`<${App} />`, document.getElementById("app"));