-
+function withTimeout(promise, timeoutMs, label) {
+ return new Promise((resolve, reject) => {
+ const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
+ promise.then((value) => {
+ clearTimeout(timerId);
+ resolve(value);
+ }).catch((err) => {
+ clearTimeout(timerId);
+ reject(err);
+ });
+ });
+}
+
+function formatInt(value) {
+ const n = Number(value || 0);
+ return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0";
+}
+
+function renderDiskUsageSection(state) {
+ const { data } = state;
+ const shell = document.getElementById("home_shell");
+ if (!shell) return;
+
+ if (state.status === "error") {
+ shell.innerHTML = `
Failed to load data: ${state.error}
`;
+ return;
+ }
+
+ if (state.status !== "ready" || !data) {
+ shell.innerHTML = "
Loading...
";
+ return;
+ }
+
+ const driveStats = data.driveStats || {};
+ const softwareInfo = data.softwareInfo || {};
+ const diskUsage = Array.isArray(data.diskUsage) ? data.diskUsage : [];
+ const diskError = Array.isArray(data.diskError) ? data.diskError : [];
+
+ const statBlock = (title, stats = {}) => `
+
+
${title}
+
${formatInt(stats.drives)}
drives
+
${formatInt(stats.distance)}
${stats.unit || state.unit}
+
${formatInt(stats.hours)}
hours
+
+ `;
+
+ const diskBlock = (disk = {}) => {
+ const usedPct = Number.parseFloat(disk.usedPercentage) || 0;
+ const rightRadius = usedPct >= 100 ? "0" : "var(--border-radius-md)";
+ return `
+
+
${disk.used || "0 GB"} used of ${disk.size || "0 GB"}
+
+
+ `;
+ };
+
+ const softwareFields = [
+ ["Branch Name", softwareInfo.branchName],
+ ["Build", softwareInfo.buildEnvironment],
+ ["Commit Hash", softwareInfo.commitHash],
+ ["Fork Maintainer", softwareInfo.forkMaintainer],
+ ["Update Available", softwareInfo.updateAvailable],
+ ["Version Date", softwareInfo.versionDate],
+ ];
+
+ const softwareMarkup = softwareFields
+ .map(([label, value]) => `
${label}: ${value ?? "Unknown"}
`)
+ .join("");
+
+ const diskMarkup = diskError.length
+ ? `
${diskError.join("
")}
`
+ : (diskUsage.length ? diskUsage.map(diskBlock).join("") : diskBlock({}));
+
+ shell.innerHTML = `
+
+
Galaxy
+
+
+ ${statBlock("All Time", driveStats.all)}
+ ${statBlock("Past Week", driveStats.week)}
+ ${statBlock("FrogPilot", driveStats.frogpilot)}
+
+
+
Disk Usage
+
+ ${diskMarkup}
+
+
+
Software Info
+
`;
}
-function DriveStat(title, stats = {}, defaultUnit) {
- const format = (n) =>
- n?.toLocaleString("en-US", {
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }) ?? "0";
-
- return html`
-
-
${title}
-
${format(stats.drives)}
drives
-
${format(stats.distance)}
${stats.unit ?? defaultUnit}
-
${format(stats.hours)}
hours
-
- `;
-}
-
-function renderSoftwareInfo(info = {}) {
- const fields = [
- ["Branch Name", info.branchName],
- ["Build", info.buildEnvironment],
- ["Commit Hash", info.commitHash],
- ["Fork Maintainer", info.forkMaintainer],
- ["Update Available", info.updateAvailable],
- ["Version Date", info.versionDate],
- ];
-
- return fields.map(
- ([label, value]) =>
- html`
${label}: ${value ?? "Unknown"}
`
- );
-}
-
-function renderDiskUsageSection({ diskError, diskUsage }) {
- if (diskError) {
- return html`
${diskError.join("
")}
`;
- }
- if (diskUsage?.length) {
- return diskUsage.map(DiskUsage);
- }
- return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" });
-}
-
-const state = reactive({
- data: null,
- unit: "miles",
- isLoading: true,
- error: null,
-});
-
-let initialized = false;
-
-async function initialize() {
+async function initializeHome() {
try {
const [statsResponse, unitResponse] = await Promise.all([
- fetch("/api/stats"),
- fetch("/api/params?key=IsMetric"),
+ withTimeout(fetch("/api/stats"), 5000, "stats request"),
+ withTimeout(fetch("/api/params?key=IsMetric"), 5000, "metric request"),
]);
- if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`);
- if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
+ if (!statsResponse.ok) throw new Error(`stats API error: ${statsResponse.status}`);
+ if (!unitResponse.ok) throw new Error(`params API error: ${unitResponse.status}`);
- const statsJson = await statsResponse.json();
- const isMetricText = (await unitResponse.text()).trim();
- const isMetric = isMetricText === "1";
+ const statsJson = await withTimeout(statsResponse.json(), 5000, "stats JSON parse");
+ const isMetricText = (await withTimeout(unitResponse.text(), 5000, "metric read")).trim();
- state.data = statsJson;
- state.unit = isMetric ? "kilometers" : "miles";
- localStorage.setItem("isMetric", isMetricText);
+ HOME_STATE.data = statsJson;
+ HOME_STATE.unit = isMetricText === "1" ? "kilometers" : "miles";
+ HOME_STATE.status = "ready";
} catch (err) {
- console.error("Failed to initialize component:", err);
- state.error = err.message;
- } finally {
- state.isLoading = false;
+ HOME_STATE.status = "error";
+ HOME_STATE.error = err?.message || String(err);
}
+
+ renderDiskUsageSection(HOME_STATE);
}
export function Home() {
- if (!initialized) {
- initialized = true;
- initialize();
- }
+ setTimeout(() => {
+ renderDiskUsageSection(HOME_STATE);
+ if (!HOME_STATE.initialized) {
+ HOME_STATE.initialized = true;
+ initializeHome();
+ }
+ }, 0);
- return html`
-
- ${() => {
- if (state.isLoading) {
- return html`
Loading...
`;
- }
-
- if (state.error) {
- return html`
Failed to load data: ${state.error}
`;
- }
-
- if (state.data) {
- const { driveStats, softwareInfo } = state.data;
- return html`
-
Galaxy
-
-
- ${DriveStat("All Time", driveStats?.all, state.unit)}
- ${DriveStat("Past Week", driveStats?.week, state.unit)}
- ${DriveStat("FrogPilot", driveStats?.frogpilot, state.unit)}
-
-
-
Disk Usage
-
- ${renderDiskUsageSection(state.data)}
-
-
-
Software Info
-
-
${renderSoftwareInfo(softwareInfo)}
-
- `;
- }
-
- return html`
No data available.
`;
- }}
-
- `;
+ return html`
`;
}
diff --git a/frogpilot/system/the_pond/assets/components/modal.js b/frogpilot/system/the_pond/assets/components/modal.js
index cd3af9e46..bb2bdb03d 100644
--- a/frogpilot/system/the_pond/assets/components/modal.js
+++ b/frogpilot/system/the_pond/assets/components/modal.js
@@ -1,4 +1,4 @@
-import { html } from "https://esm.sh/@arrow-js/core";
+import { html } from "/assets/vendor/arrow-core.js";
export function Modal({
title,
diff --git a/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js b/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js
index 6be8d6406..755308fc9 100644
--- a/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js
+++ b/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js
@@ -1,4 +1,4 @@
-import { html, reactive } from "https://esm.sh/@arrow-js/core";
+import { html, reactive } from "/assets/vendor/arrow-core.js";
import {
addRouteToMap,
formatMetersToHuman,
diff --git a/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js b/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js
index 3fd881811..9ce319038 100644
--- a/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js
+++ b/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.js
@@ -1,4 +1,4 @@
-import { html, reactive } from "https://esm.sh/@arrow-js/core"
+import { html, reactive } from "/assets/vendor/arrow-core.js"
import { Modal } from "/assets/components/modal.js";
export function NavKeys() {
diff --git a/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js b/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js
index 7f82fdb91..0eec5f34e 100644
--- a/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js
+++ b/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js
@@ -1,4 +1,4 @@
-import { html, reactive } from "https://esm.sh/@arrow-js/core"
+import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
import { Modal } from "/assets/components/modal.js";
@@ -13,8 +13,18 @@ const state = reactive({
total: 0,
showDeleteAllModal: false,
isDeletingAll: false,
+ truncated: false,
})
+const MAX_RENDERED_ROUTES = 250
+const ROUTE_FLUSH_INTERVAL_MS = 120
+
+let routesAbortController = null
+let routesRequestToken = 0
+let pendingRoutes = []
+let flushTimerId = null
+let seenRouteNames = new Set()
+
function formatRouteDate(dateString) {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
@@ -32,10 +42,73 @@ function formatRouteDate(dateString) {
return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}`
}
+function resetRouteStreamState() {
+ pendingRoutes = []
+ if (flushTimerId !== null) {
+ clearTimeout(flushTimerId)
+ flushTimerId = null
+ }
+ seenRouteNames = new Set()
+}
+
+function flushPendingRoutes() {
+ if (pendingRoutes.length === 0) return
+
+ const availableSlots = Math.max(MAX_RENDERED_ROUTES - state.routes.length, 0)
+ if (availableSlots <= 0) {
+ pendingRoutes = []
+ state.truncated = true
+ return
+ }
+
+ const toAppend = pendingRoutes.slice(0, availableSlots)
+ pendingRoutes = []
+ if (toAppend.length > 0) {
+ state.routes = [...state.routes, ...toAppend]
+ }
+ if (state.routes.length >= MAX_RENDERED_ROUTES) {
+ state.truncated = true
+ }
+}
+
+function enqueueRoutes(rawRoutes) {
+ if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) return
+
+ const nextRoutes = []
+ for (const route of rawRoutes) {
+ const name = String(route?.name || "")
+ if (!name || seenRouteNames.has(name)) continue
+ seenRouteNames.add(name)
+ nextRoutes.push({
+ ...route,
+ timestamp: formatRouteDate(route.timestamp),
+ })
+ }
+
+ if (nextRoutes.length === 0) return
+ pendingRoutes.push(...nextRoutes)
+
+ if (flushTimerId === null) {
+ flushTimerId = setTimeout(() => {
+ flushTimerId = null
+ flushPendingRoutes()
+ }, ROUTE_FLUSH_INTERVAL_MS)
+ }
+}
+
async function fetchRoutes() {
+ const requestToken = ++routesRequestToken
+ if (routesAbortController) {
+ routesAbortController.abort()
+ }
+ const controller = new AbortController()
+ routesAbortController = controller
+
try {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
- const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`);
+ const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, {
+ signal: controller.signal,
+ });
if (!response.ok) throw new Error();
const reader = response.body.getReader();
@@ -46,24 +119,24 @@ async function fetchRoutes() {
const { value, done } = await reader.read();
if (done) break;
+ if (requestToken !== routesRequestToken) return
+
buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split("\n\n");
+ const lines = buffer.split(/\r?\n\r?\n/);
buffer = lines.pop();
for (const line of lines) {
- if (line.startsWith("data: ")) {
+ if (line.startsWith("data:")) {
try {
- const data = JSON.parse(line.substring(6));
+ const payload = line.substring(5).trim()
+ if (!payload) continue
+ const data = JSON.parse(payload);
if (data.progress !== undefined && data.total !== undefined) {
state.progress = data.progress;
state.total = data.total;
}
if (data.routes) {
- const routes = data.routes.map(route => ({
- ...route,
- timestamp: formatRouteDate(route.timestamp),
- }));
- state.routes.push(...routes);
+ enqueueRoutes(data.routes)
}
} catch (e) {
console.error("Failed to parse JSON:", e);
@@ -71,21 +144,35 @@ async function fetchRoutes() {
}
}
}
- } catch (_) {
- state.error = "Couldn't load routes. Please try again later..."
+ flushPendingRoutes()
+ } catch (error) {
+ if (error?.name !== "AbortError") {
+ state.error = "Couldn't load routes. Please try again later..."
+ }
} finally {
- state.loading = false
+ if (requestToken === routesRequestToken) {
+ flushPendingRoutes()
+ state.loading = false
+ if (routesAbortController === controller) {
+ routesAbortController = null
+ }
+ }
}
}
-fetchRoutes()
-
function refresh() {
state.loading = true
+ state.error = null
state.routes = []
+ state.progress = 0
+ state.total = 0
+ state.truncated = false
+ resetRouteStreamState()
fetchRoutes()
}
+refresh()
+
let overlay = null
function openDialog(htmlStr) {
@@ -391,6 +478,9 @@ export function RouteRecordings() {
Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.
+ ` : ""}
${() => {
if (state.routes.length > 0) {
return html`
diff --git a/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js b/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js
index 9ed184988..09958e47c 100644
--- a/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js
+++ b/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js
@@ -1,4 +1,4 @@
-import { html, reactive } from "https://esm.sh/@arrow-js/core"
+import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js";
diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js
index a0ff96bc6..68d6659f6 100644
--- a/frogpilot/system/the_pond/assets/components/router.js
+++ b/frogpilot/system/the_pond/assets/components/router.js
@@ -1,5 +1,5 @@
-import { html, reactive } from "https://esm.sh/@arrow-js/core"
-import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
+import { html, reactive } from "/assets/vendor/arrow-core.js"
+import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
@@ -25,6 +25,15 @@ import { UpdateManager } from "/assets/components/tools/update_manager.js"
let router, routerState
+function toRouterHref(href) {
+ try {
+ const url = new URL(href, window.location.origin)
+ return `${url.pathname}${url.search}${url.hash}`
+ } catch {
+ return href
+ }
+}
+
function createRoute(id, path, component) {
return {
id,
@@ -34,6 +43,20 @@ function createRoute(id, path, component) {
}
}
+function SafeHome() {
+ return html`
+
+ `
+}
+
function Root() {
let routes = [
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
@@ -63,6 +86,11 @@ function Root() {
history: createBrowserHistory(),
}).initialize()
+ window.__thePondNavigate = (href) => {
+ router.navigate(toRouterHref(href))
+ window.scrollTo(0, 0)
+ }
+
routerState = reactive({
activePath: "/",
activePathFull: "/",
@@ -70,10 +98,13 @@ function Root() {
navigation: { state: "loading" },
errors: [],
params: {},
+ activeMatch: null,
})
router.subscribe(({ initialized, navigation, matches, errors }) => {
- const [match] = matches || []
+ const match = Array.isArray(matches) && matches.length > 0
+ ? matches[matches.length - 1]
+ : null
Object.assign(routerState, {
initialized,
activePath: match?.route?.path || "",
@@ -81,6 +112,7 @@ function Root() {
navigation,
params: match?.params || {},
errors,
+ activeMatch: match,
})
})
@@ -96,12 +128,12 @@ function Root() {
return html`
`
}
- const match = routes.find(r => r.path === routerState.activePath)
- if (!match) {
- console.warn("[router] no route match for path:", routerState.activePathFull)
+ const routeElement = routerState.activeMatch?.route?.element
+ if (typeof routeElement !== "function") {
+ console.warn("[router] no route element for path:", routerState.activePathFull)
return Home({ params: routerState.params })
}
- return match.element({ params: routerState.params })
+ return routeElement({ params: routerState.params })
}}