Spaces:
Sleeping
Sleeping
| /* 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: "© OpenStreetMap, © 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")); | |