From 876a45122fcd7c4b755fc4cdf7bc169f32e36fec Mon Sep 17 00:00:00 2001
From: firestar5683 <168790843+firestar5683@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:38:32 -0600
Subject: [PATCH] Model Manager/Galaxy
---
.../the_pond/assets/components/home/home.js | 61 +-
.../the_pond/assets/components/router.js | 25 +-
.../the_pond/assets/components/sidebar.js | 1 +
.../components/tools/device_settings.js | 8 +-
.../assets/components/tools/model_manager.css | 253 ++++++++
.../assets/components/tools/model_manager.js | 583 ++++++++++++++++++
.../assets/components/tools/tsk_manager.js | 19 +-
.../system/the_pond/templates/index.html | 5 +-
frogpilot/system/the_pond/the_pond.py | 394 +++++++++++-
9 files changed, 1281 insertions(+), 68 deletions(-)
create mode 100644 frogpilot/system/the_pond/assets/components/tools/model_manager.css
create mode 100644 frogpilot/system/the_pond/assets/components/tools/model_manager.js
diff --git a/frogpilot/system/the_pond/assets/components/home/home.js b/frogpilot/system/the_pond/assets/components/home/home.js
index 7a700cb81..b4247fc01 100644
--- a/frogpilot/system/the_pond/assets/components/home/home.js
+++ b/frogpilot/system/the_pond/assets/components/home/home.js
@@ -64,40 +64,45 @@ function renderDiskUsageSection({ diskError, diskUsage }) {
return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" });
}
-export function Home() {
- const state = reactive({
- data: null,
- unit: "miles",
- isLoading: true,
- error: null,
- });
+const state = reactive({
+ data: null,
+ unit: "miles",
+ isLoading: true,
+ error: null,
+});
- async function initialize() {
- try {
- const [statsResponse, unitResponse] = await Promise.all([
- fetch("/api/stats"),
- fetch("/api/params?key=IsMetric"),
- ]);
+let initialized = false;
- if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`);
- if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
+async function initialize() {
+ try {
+ const [statsResponse, unitResponse] = await Promise.all([
+ fetch("/api/stats"),
+ fetch("/api/params?key=IsMetric"),
+ ]);
- const statsJson = await statsResponse.json();
- const isMetricText = (await unitResponse.text()).trim();
- const isMetric = isMetricText === "1";
+ if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`);
+ if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
- state.data = statsJson;
- state.unit = isMetric ? "kilometers" : "miles";
- localStorage.setItem("isMetric", isMetricText);
- } catch (err) {
- console.error("Failed to initialize component:", err);
- state.error = err.message;
- } finally {
- state.isLoading = false;
- }
+ const statsJson = await statsResponse.json();
+ const isMetricText = (await unitResponse.text()).trim();
+ const isMetric = isMetricText === "1";
+
+ state.data = statsJson;
+ state.unit = isMetric ? "kilometers" : "miles";
+ localStorage.setItem("isMetric", isMetricText);
+ } catch (err) {
+ console.error("Failed to initialize component:", err);
+ state.error = err.message;
+ } finally {
+ state.isLoading = false;
}
+}
- initialize();
+export function Home() {
+ if (!initialized) {
+ initialized = true;
+ initialize();
+ }
return html`
diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js
index 657c2e5a9..c17e80575 100644
--- a/frogpilot/system/the_pond/assets/components/router.js
+++ b/frogpilot/system/the_pond/assets/components/router.js
@@ -12,6 +12,7 @@ import { SettingsView } from "/assets/components/settings.js"
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
import { Sidebar } from "/assets/components/sidebar.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
+import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260303t"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
@@ -40,6 +41,7 @@ function Root() {
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
+ createRoute("model_manager", "/manage_models", ModelManager),
createRoute("thememaker", "/theme_maker", ThemeMaker),
createRoute("tmux", "/manage_tmux", TmuxLog),
createRoute("toggles", "/manage_toggles", ToggleControl),
@@ -61,13 +63,13 @@ function Root() {
})
router.subscribe(({ initialized, navigation, matches, errors }) => {
- const [match] = matches
+ const [match] = matches || []
Object.assign(routerState, {
initialized,
- activePath: match.route.path,
- activePathFull: match.pathname,
+ activePath: match?.route?.path || "",
+ activePathFull: match?.pathname || "",
navigation,
- params: match.params,
+ params: match?.params || {},
errors,
})
})
@@ -76,8 +78,8 @@ function Root() {
${() => Sidebar(routerState.activePathFull)}
${() => {
- if (!routerState.initialized || routerState.navigation.state === "loading") {
- return html`
Loading...
`
+ if (!routerState.initialized) {
+ return Home({ params: routerState.params })
}
if (routerState.errors?.root?.status === 404) {
@@ -85,6 +87,10 @@ function Root() {
}
const match = routes.find(r => r.path === routerState.activePath)
+ if (!match) {
+ console.warn("[router] no route match for path:", routerState.activePathFull)
+ return Home({ params: routerState.params })
+ }
return match.element({ params: routerState.params })
}}
@@ -109,4 +115,9 @@ export function Navigate(href) {
window.scrollTo(0, 0)
}
-Root()(document.getElementById("app"))
+if (!window.__thePondRouterMounted) {
+ window.__thePondRouterMounted = true
+ Root()(document.getElementById("app"))
+} else {
+ console.warn("[router] duplicate mount prevented")
+}
diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js
index 5fbcca1e0..7dc930fb1 100644
--- a/frogpilot/system/the_pond/assets/components/sidebar.js
+++ b/frogpilot/system/the_pond/assets/components/sidebar.js
@@ -19,6 +19,7 @@ const MenuItems = {
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" },
+ { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
{ name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" },
diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.js b/frogpilot/system/the_pond/assets/components/tools/device_settings.js
index 5a7d3204e..f04abf2a8 100644
--- a/frogpilot/system/the_pond/assets/components/tools/device_settings.js
+++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.js
@@ -23,7 +23,13 @@ async function fetchLayoutAndParams() {
// 1. Fetch Layout Structure (Build-time Static JSON)
try {
const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json")
- const layoutData = await layoutRes.json()
+ const rawLayoutData = await layoutRes.json()
+ const layoutData = rawLayoutData
+ .map(section => ({
+ ...section,
+ params: (section.params || []).filter(param => param.key !== "Model"),
+ }))
+ .filter(section => section.params.length > 0)
state.layout = layoutData
// Extract flatter key map
diff --git a/frogpilot/system/the_pond/assets/components/tools/model_manager.css b/frogpilot/system/the_pond/assets/components/tools/model_manager.css
new file mode 100644
index 000000000..42092fa84
--- /dev/null
+++ b/frogpilot/system/the_pond/assets/components/tools/model_manager.css
@@ -0,0 +1,253 @@
+.mm-wrapper {
+ color: var(--text-color);
+ max-width: 1200px;
+ padding-right: var(--padding-base);
+}
+
+.mm-toolbar {
+ align-items: center;
+ background: var(--card-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-lg);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--gap-md);
+ justify-content: space-between;
+ margin: var(--margin-base) 0;
+ padding: var(--padding-base);
+}
+
+.mm-summary {
+ color: var(--text-muted);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--gap-lg);
+}
+
+.mm-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--gap-sm);
+}
+
+.mm-status {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--gap-sm);
+ margin-bottom: var(--margin-base);
+}
+
+.mm-progress {
+ background: var(--secondary-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-base);
+ color: var(--text-color);
+ padding: 0.35rem 0.6rem;
+}
+
+.mm-filters {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--gap-sm);
+ margin-bottom: var(--margin-base);
+}
+
+.mm-search,
+.mm-select {
+ background: var(--input-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-base);
+ color: var(--text-color);
+ font-size: var(--font-size-base);
+ min-height: 2.15rem;
+ padding: 0.4rem 0.55rem;
+}
+
+.mm-search {
+ min-width: min(420px, 90vw);
+}
+
+.mm-filter-label,
+.mm-filter-checkbox {
+ align-items: center;
+ color: var(--text-muted);
+ display: flex;
+ gap: var(--gap-xs);
+}
+
+.mm-filter-break {
+ display: none;
+}
+
+.mm-series {
+ background: var(--card-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-lg);
+ margin-bottom: var(--margin-base);
+ overflow: hidden;
+}
+
+.mm-series-header {
+ align-items: center;
+ background: rgba(255, 255, 255, 0.03);
+ border-bottom: 1px solid var(--sidebar-border-color);
+ display: flex;
+ justify-content: space-between;
+ padding: 0.55rem 0.8rem;
+}
+
+.mm-series-header h3 {
+ font-size: 1.02rem;
+ margin: 0;
+ padding: 0;
+}
+
+.mm-series-header span {
+ color: var(--text-muted);
+}
+
+.mm-row {
+ align-items: center;
+ border-bottom: 1px solid var(--sidebar-border-color);
+ display: flex;
+ gap: var(--gap-md);
+ justify-content: space-between;
+ padding: 0.75rem 0.8rem;
+}
+
+.mm-row:last-child {
+ border-bottom: none;
+}
+
+.mm-row-main {
+ min-width: 0;
+}
+
+.mm-row-title {
+ align-items: center;
+ display: flex;
+ font-size: 1rem;
+ gap: var(--gap-xs);
+}
+
+.mm-row-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-top: 0.35rem;
+}
+
+.mm-row-actions {
+ align-items: center;
+ display: flex;
+ flex-shrink: 0;
+ gap: var(--gap-sm);
+}
+
+.mm-btn {
+ border: none;
+ border-radius: var(--border-radius-base);
+ color: var(--text-color);
+ min-height: 2.2rem;
+ min-width: 7.5rem;
+ padding: 0.45rem 0.8rem;
+}
+
+.mm-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.mm-btn-primary {
+ background: var(--success-bg);
+ color: var(--color-black);
+}
+
+.mm-btn-primary:hover:not(:disabled) {
+ background: var(--success-hover-bg);
+}
+
+.mm-btn-secondary {
+ background: var(--sidebar-active-bg);
+}
+
+.mm-btn-secondary:hover:not(:disabled) {
+ background: var(--accent-hover-bg);
+}
+
+.mm-btn-danger {
+ background: var(--danger-bg);
+}
+
+.mm-btn-danger:hover:not(:disabled) {
+ background: var(--danger-hover-bg);
+}
+
+.mm-chip {
+ background: var(--secondary-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: 999px;
+ color: var(--text-muted);
+ display: inline-flex;
+ font-size: 0.78rem;
+ padding: 0.1rem 0.5rem;
+}
+
+.mm-chip-active {
+ background: var(--success-bg);
+ border-color: transparent;
+ color: var(--color-black);
+}
+
+.mm-chip-favorite {
+ background: rgba(212, 160, 96, 0.18);
+ border-color: rgba(212, 160, 96, 0.5);
+ color: var(--warning-bg);
+}
+
+.mm-chip-warning {
+ background: rgba(224, 85, 119, 0.12);
+ border-color: rgba(224, 85, 119, 0.45);
+ color: var(--danger-fg);
+}
+
+.mm-star {
+ background: transparent;
+ border: none;
+ color: #e0b45a;
+ font-size: 1rem;
+ line-height: 1;
+ padding: 0;
+}
+
+.mm-empty {
+ color: var(--text-muted);
+ padding: 0.7rem 0;
+}
+
+@media (max-width: 850px) {
+ .mm-row {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .mm-row-actions {
+ width: 100%;
+ }
+
+ .mm-btn {
+ width: 100%;
+ }
+
+ .mm-search {
+ min-width: 100%;
+ }
+
+ .mm-filter-break {
+ display: block;
+ flex-basis: 100%;
+ height: 0;
+ }
+}
diff --git a/frogpilot/system/the_pond/assets/components/tools/model_manager.js b/frogpilot/system/the_pond/assets/components/tools/model_manager.js
new file mode 100644
index 000000000..35f81a263
--- /dev/null
+++ b/frogpilot/system/the_pond/assets/components/tools/model_manager.js
@@ -0,0 +1,583 @@
+import { html, reactive } from "https://esm.sh/@arrow-js/core";
+
+const state = reactive({
+ loading: true,
+ refreshing: false,
+ error: "",
+ actionBusy: false,
+ sortMode: "alphabetical",
+ communityFavoriteFilter: "all",
+ models: [],
+ currentModel: "",
+ summary: { installed: 0, missing: 0, total: 0 },
+ status: {
+ modelToDownload: "",
+ downloadAll: false,
+ downloading: false,
+ cancelling: false,
+ progress: "",
+ isOnroad: false,
+ terminal: false,
+ },
+});
+
+let initialized = false;
+let pollingHandle = null;
+let statusInFlight = false;
+let lastStatusSignature = "";
+
+const REQUEST_TIMEOUT_MS = 20000;
+const ACTIVE_POLL_INTERVAL_MS = 1000;
+const IDLE_POLL_INTERVAL_MS = 4000;
+
+function notify(message, variant = "success") {
+ if (typeof showSnackbar === "function") {
+ showSnackbar(message, variant);
+ } else if (variant === "error") {
+ console.error(message);
+ } else {
+ console.log(message);
+ }
+}
+
+function logDebug(message, details = null) {
+ if (details === null || details === undefined) {
+ console.log(`[ModelManager] ${message}`);
+ } else {
+ console.log(`[ModelManager] ${message}`, details);
+ }
+}
+
+function isModelRouteActive() {
+ return window.location.pathname === "/manage_models";
+}
+
+function safeText(value, fallback = "") {
+ if (value === null || value === undefined) return fallback;
+ return String(value);
+}
+
+function toBool(value) {
+ return !!value;
+}
+
+function toInt(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function parseReleased(value) {
+ const ts = Date.parse(safeText(value, ""));
+ return Number.isNaN(ts) ? 0 : ts;
+}
+
+function normalizeSeries(model) {
+ return safeText(model?.series, "Custom Series") || "Custom Series";
+}
+
+function modelSortCompare(a, b) {
+ if (state.sortMode === "release_date") {
+ const dateDelta = parseReleased(b?.released) - parseReleased(a?.released);
+ if (dateDelta !== 0) return dateDelta;
+ }
+
+ return safeText(a?.label, a?.value).localeCompare(
+ safeText(b?.label, b?.value),
+ undefined,
+ { sensitivity: "base", numeric: true },
+ );
+}
+
+function getFilteredModels() {
+ let rows = [...state.models].filter(model => model && typeof model === "object");
+
+ if (state.communityFavoriteFilter === "yes") {
+ rows = rows.filter(model => !!model.communityFavorite);
+ } else if (state.communityFavoriteFilter === "no") {
+ rows = rows.filter(model => !model.communityFavorite);
+ }
+
+ return rows;
+}
+
+function getSeriesGroups() {
+ const grouped = {};
+
+ for (const model of getFilteredModels()) {
+ const seriesName = normalizeSeries(model);
+ if (!grouped[seriesName]) grouped[seriesName] = [];
+ grouped[seriesName].push(model);
+ }
+
+ const seriesNames = Object.keys(grouped);
+ for (const seriesName of seriesNames) {
+ grouped[seriesName].sort(modelSortCompare);
+ }
+
+ if (state.sortMode === "release_date") {
+ seriesNames.sort((a, b) => {
+ const aNewest = Math.max(...grouped[a].map(model => parseReleased(model?.released)));
+ const bNewest = Math.max(...grouped[b].map(model => parseReleased(model?.released)));
+ const delta = bNewest - aNewest;
+ if (delta !== 0) return delta;
+ return a.localeCompare(b, undefined, { sensitivity: "base" });
+ });
+ } else {
+ seriesNames.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
+ }
+
+ return { grouped, seriesNames };
+}
+
+function getVisibleModels() {
+ const { grouped, seriesNames } = getSeriesGroups();
+ const rows = [];
+ for (const seriesName of seriesNames) {
+ rows.push(...grouped[seriesName]);
+ }
+ return rows;
+}
+
+function getReleaseOrderedModels() {
+ return getFilteredModels().sort(modelSortCompare);
+}
+
+function getInstalledModels() {
+ const rows = state.sortMode === "release_date" ? getReleaseOrderedModels() : getVisibleModels();
+ return rows.filter(model => !!model.installed);
+}
+
+function getCurrentModelName() {
+ const current = safeText(state.currentModel, "");
+ if (!current) return "none";
+
+ const match = state.models.find(model => safeText(model?.value, "") === current);
+ if (!match) return current;
+
+ return safeText(match.label, current);
+}
+
+async function fetchJson(url, options = {}) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(url, { ...options, signal: controller.signal });
+
+ let payload = {};
+ try {
+ payload = await response.json();
+ } catch {
+ payload = {};
+ }
+
+ if (!response.ok) {
+ const message = payload?.error || payload?.message || `Request failed (${response.status})`;
+ throw new Error(message);
+ }
+
+ return payload;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+async function fetchStatus() {
+ if (statusInFlight) return;
+ statusInFlight = true;
+
+ try {
+ const payload = await fetchJson("/api/models/status");
+
+ const models = Array.isArray(payload.models)
+ ? payload.models.filter(model => model && typeof model === "object")
+ : [];
+
+ state.models = models;
+ state.currentModel = safeText(payload.currentModel, "");
+
+ const summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {};
+ state.summary = {
+ installed: toInt(summary.installed),
+ missing: toInt(summary.missing),
+ total: toInt(summary.total),
+ };
+
+ state.status = {
+ modelToDownload: safeText(payload.modelToDownload, ""),
+ downloadAll: toBool(payload.downloadAll),
+ downloading: toBool(payload.downloading),
+ cancelling: toBool(payload.cancelling),
+ progress: safeText(payload.progress, ""),
+ isOnroad: toBool(payload.isOnroad),
+ terminal: toBool(payload.terminal),
+ };
+
+ state.error = "";
+
+ const signature = [
+ state.models.length,
+ state.currentModel,
+ state.status.downloading,
+ state.status.downloadAll,
+ state.status.modelToDownload,
+ state.status.progress,
+ ].join("|");
+
+ if (signature !== lastStatusSignature) {
+ lastStatusSignature = signature;
+ logDebug("Status updated", {
+ models: state.models.length,
+ currentModel: state.currentModel || "none",
+ downloading: state.status.downloading,
+ progress: state.status.progress || "Idle",
+ });
+ }
+ } catch (error) {
+ state.error = error?.message || String(error);
+ logDebug("Status fetch failed", state.error);
+ } finally {
+ statusInFlight = false;
+ state.loading = false;
+ state.refreshing = false;
+ }
+}
+
+async function refreshAll(showToast = false) {
+ state.refreshing = true;
+ if (state.models.length === 0) {
+ state.loading = true;
+ }
+
+ await fetchStatus();
+
+ if (showToast && !state.error) {
+ notify("Model list refreshed.");
+ }
+}
+
+function ensurePolling() {
+ if (pollingHandle) return;
+
+ const poll = async () => {
+ let nextDelay = IDLE_POLL_INTERVAL_MS;
+ try {
+ if (isModelRouteActive()) {
+ await fetchStatus();
+ nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
+ }
+ } finally {
+ pollingHandle = setTimeout(poll, nextDelay);
+ }
+ };
+
+ pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS);
+}
+
+async function setActiveModel(modelKey) {
+ const payload = await fetchJson("/api/params", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ key: "Model", value: modelKey }),
+ });
+
+ notify(payload.message || `Selected "${modelKey}".`);
+}
+
+async function startDownload(modelKey) {
+ const payload = await fetchJson("/api/models/download", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: modelKey }),
+ });
+
+ notify(payload.message || `Downloading "${modelKey}"...`);
+}
+
+async function startDownloadAll() {
+ const payload = await fetchJson("/api/models/download_all", { method: "POST" });
+ notify(payload.message || "Started downloading all models.");
+}
+
+async function cancelDownload() {
+ const payload = await fetchJson("/api/models/cancel", { method: "POST" });
+ notify(payload.message || "Cancellation requested.");
+}
+
+async function deleteModel(modelKey) {
+ const payload = await fetchJson("/api/models/delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: modelKey }),
+ });
+
+ notify(payload.message || `Deleted files for "${modelKey}".`);
+}
+
+async function refreshManifest() {
+ const payload = await fetchJson("/api/models/refresh_manifest", { method: "POST" });
+ notify(payload.message || "Model manifest refreshed.");
+}
+
+async function runAction(action, modelKey = "") {
+ if (state.actionBusy) {
+ notify("Please wait for the current action to finish.", "error");
+ return;
+ }
+
+ state.actionBusy = true;
+ try {
+ if (action === "refresh") {
+ await refreshManifest();
+ await refreshAll(false);
+ return;
+ }
+
+ if (state.status.isOnroad && action !== "refresh") {
+ notify("Actions are blocked while onroad.", "error");
+ return;
+ }
+
+ if (action === "select") {
+ if (!modelKey) return;
+ await setActiveModel(modelKey);
+ } else if (action === "download") {
+ if (!modelKey) return;
+ await startDownload(modelKey);
+ } else if (action === "download-all") {
+ await startDownloadAll();
+ } else if (action === "cancel") {
+ await cancelDownload();
+ } else if (action === "delete") {
+ if (!modelKey) return;
+ const confirmed = window.confirm(`Delete local files for model \"${modelKey}\"?`);
+ if (!confirmed) return;
+ await deleteModel(modelKey);
+ }
+
+ await fetchStatus();
+ } catch (error) {
+ notify(error?.message || String(error), "error");
+ } finally {
+ state.actionBusy = false;
+ }
+}
+
+function bindDomHandlers() {
+ if (window.__modelManagerHandlersBound) return;
+ window.__modelManagerHandlersBound = true;
+
+ document.addEventListener("click", event => {
+ if (!isModelRouteActive()) return;
+
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+
+ const button = target.closest("[data-mm-action]");
+ if (!button) return;
+
+ const action = safeText(button.getAttribute("data-mm-action"), "");
+ const modelKey = safeText(button.getAttribute("data-model"), "");
+
+ runAction(action, modelKey).catch(() => {});
+ });
+
+ document.addEventListener("change", event => {
+ if (!isModelRouteActive()) return;
+
+ const target = event.target;
+ if (!(target instanceof HTMLSelectElement)) return;
+ if (target.id === "mm-active-model-select") {
+ const modelKey = safeText(target.value, "");
+ if (!modelKey) return;
+ runAction("select", modelKey).catch(() => {});
+ return;
+ }
+
+ if (target.id === "mm-sort-mode-select") {
+ const value = safeText(target.value, "alphabetical");
+ state.sortMode = value === "release_date" ? "release_date" : "alphabetical";
+ return;
+ }
+
+ if (target.id === "mm-community-filter-select") {
+ const value = safeText(target.value, "all");
+ if (value === "yes" || value === "no" || value === "all") {
+ state.communityFavoriteFilter = value;
+ } else {
+ state.communityFavoriteFilter = "all";
+ }
+ }
+ });
+}
+
+function renderActions(model) {
+ const modelKey = safeText(model.value, "");
+ const modelIsDownloading = state.status.downloading && !state.status.downloadAll && state.status.modelToDownload === modelKey;
+
+ if (state.currentModel === modelKey) {
+ return html`
Active`;
+ }
+
+ if (state.status.downloading) {
+ if (state.status.downloadAll || modelIsDownloading) {
+ return html`
`;
+ }
+ return html`
Busy`;
+ }
+
+ if (model.installed) {
+ return html`
+
+
+ `;
+ }
+
+ return html`
`;
+}
+
+function renderModelRow(model) {
+ const label = safeText(model.label, safeText(model.value, "Unnamed"));
+ const key = safeText(model.value, "");
+
+ return html`
+
+
+
+ ${label}
+
+
+ ${key}
+ ${state.sortMode === "release_date" ? "" : model.series ? html`${safeText(model.series)}` : ""}
+ ${model.version ? html`Version ${safeText(model.version)}` : ""}
+ ${model.released ? html`Released ${safeText(model.released)}` : ""}
+ ${model.communityFavorite ? html`Community Favorite` : ""}
+ ${model.partial ? html`Partial Files` : ""}
+
+
+
+ ${renderActions(model)}
+
+
+ `;
+}
+
+function renderSeriesSection(seriesName, models) {
+ return html`
+
+
+
+ ${models.map(model => renderModelRow(model))}
+
+
+ `;
+}
+
+export function ModelManager() {
+ if (!initialized) {
+ initialized = true;
+ bindDomHandlers();
+ logDebug("Initializing component");
+ refreshAll().catch(error => {
+ state.error = error?.message || String(error);
+ state.loading = false;
+ state.refreshing = false;
+ logDebug("Initial refresh failed", state.error);
+ });
+ ensurePolling();
+ }
+
+ return html`
+
+
Model Manager
+
+ ${() => state.error ? html`
${state.error}
` : ""}
+
+
+ Available Models=${() => state.models.length}
+ Current Model=${() => getCurrentModelName()}
+
+
+
+
+
+ Current: ${getCurrentModelName()}
+ Progress: ${safeText(state.status.progress, "Idle")}
+ ${() => state.status.isOnroad ? html`Onroad: actions disabled` : ""}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${() => state.loading ? html`
Loading models...
` : ""}
+
+ ${() => !state.loading ? html`
+
+ ${(() => {
+ if (state.sortMode === "release_date") {
+ const models = getReleaseOrderedModels();
+ return models.length === 0
+ ? html`
No models available.
`
+ : models.map(model => renderModelRow(model));
+ }
+
+ const { grouped, seriesNames } = getSeriesGroups();
+ return seriesNames.length === 0
+ ? html`
No models available.
`
+ : seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName]));
+ })()}
+
+ ` : ""}
+
+ `;
+}
diff --git a/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js b/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js
index 9324a2ca0..9ce77aedf 100644
--- a/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js
+++ b/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js
@@ -165,15 +165,16 @@ const api = {
}
}
-let hasLoaded = false
-if (!hasLoaded) {
- hasLoaded = true
- api.load()
-}
-
-export function TSKManager() {
- return html`
-
+let hasLoaded = false
+
+export function TSKManager() {
+ if (!hasLoaded) {
+ hasLoaded = true
+ api.load()
+ }
+
+ return html`
+
Toyota Security Key Manager
diff --git a/frogpilot/system/the_pond/templates/index.html b/frogpilot/system/the_pond/templates/index.html
index d4c5f9035..a731efdf2 100644
--- a/frogpilot/system/the_pond/templates/index.html
+++ b/frogpilot/system/the_pond/templates/index.html
@@ -7,7 +7,7 @@
-
+
@@ -28,6 +28,7 @@
+
@@ -59,4 +60,4 @@