Model Manager/Galaxy

This commit is contained in:
firestar5683
2026-03-03 14:38:32 -06:00
parent f538eddb87
commit 876a45122f
9 changed files with 1281 additions and 68 deletions
@@ -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`
<div>
@@ -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)}
<div class="content">
${() => {
if (!routerState.initialized || routerState.navigation.state === "loading") {
return html`<div>Loading...</div>`
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 })
}}
</div>
@@ -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")
}
@@ -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" },
@@ -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
@@ -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;
}
}
@@ -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`<span class="mm-chip mm-chip-active">Active</span>`;
}
if (state.status.downloading) {
if (state.status.downloadAll || modelIsDownloading) {
return html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel</button>`;
}
return html`<span class="mm-chip">Busy</span>`;
}
if (model.installed) {
return html`
<button class="mm-btn mm-btn-secondary" data-mm-action="select" data-model="${modelKey}">Set Active</button>
<button class="mm-btn mm-btn-danger" data-mm-action="delete" data-model="${modelKey}">Delete</button>
`;
}
return html`<button class="mm-btn mm-btn-primary" data-mm-action="download" data-model="${modelKey}">Download</button>`;
}
function renderModelRow(model) {
const label = safeText(model.label, safeText(model.value, "Unnamed"));
const key = safeText(model.value, "");
return html`
<div class="mm-row">
<div class="mm-row-main">
<div class="mm-row-title">
<span>${label}</span>
</div>
<div class="mm-row-meta">
<span class="mm-chip">${key}</span>
${state.sortMode === "release_date" ? "" : model.series ? html`<span class="mm-chip">${safeText(model.series)}</span>` : ""}
${model.version ? html`<span class="mm-chip">Version ${safeText(model.version)}</span>` : ""}
${model.released ? html`<span class="mm-chip">Released ${safeText(model.released)}</span>` : ""}
${model.communityFavorite ? html`<span class="mm-chip mm-chip-favorite">Community Favorite</span>` : ""}
${model.partial ? html`<span class="mm-chip mm-chip-warning">Partial Files</span>` : ""}
</div>
</div>
<div class="mm-row-actions">
${renderActions(model)}
</div>
</div>
`;
}
function renderSeriesSection(seriesName, models) {
return html`
<section class="mm-series">
<header class="mm-series-header">
<h3>${seriesName}</h3>
<span>${models.length}</span>
</header>
<div class="mm-series-body">
${models.map(model => renderModelRow(model))}
</div>
</section>
`;
}
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`
<div class="mm-wrapper">
<h2>Model Manager</h2>
${() => state.error ? html`<div class="mm-error">${state.error}</div>` : ""}
<div class="mm-debug">
Available Models=${() => state.models.length}
Current Model=${() => getCurrentModelName()}
</div>
<div class="mm-toolbar">
<div class="mm-summary">
<span><b>${state.summary.installed}</b> installed</span>
<span><b>${state.summary.missing}</b> missing</span>
<span><b>${state.summary.total}</b> total</span>
</div>
<div class="mm-actions">
${() => state.status.downloading
? html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel Download</button>`
: html`<button class="mm-btn mm-btn-primary" data-mm-action="download-all">Download All Missing</button>`}
<button class="mm-btn mm-btn-secondary" data-mm-action="refresh">Refresh</button>
</div>
</div>
<div class="mm-status">
<span class="mm-chip">Current: ${getCurrentModelName()}</span>
<span class="mm-chip">Progress: ${safeText(state.status.progress, "Idle")}</span>
${() => state.status.isOnroad ? html`<span class="mm-chip mm-chip-warning">Onroad: actions disabled</span>` : ""}
</div>
<div class="mm-filters">
<label class="mm-filter-label" for="mm-active-model-select">Active Model</label>
<select class="mm-select" id="mm-active-model-select">
${(() => {
const orderedInstalled = getInstalledModels().sort((a, b) => {
const aCurrent = safeText(a.value) === state.currentModel ? 0 : 1;
const bCurrent = safeText(b.value) === state.currentModel ? 0 : 1;
if (aCurrent !== bCurrent) return aCurrent - bCurrent;
return safeText(a.label, a.value).localeCompare(safeText(b.label, b.value), undefined, { sensitivity: "base" });
});
return orderedInstalled.length > 0
? orderedInstalled.map(model => html`
<option value="${safeText(model.value)}" ${safeText(model.value) === state.currentModel ? "selected" : ""}>
${safeText(model.label, model.value)}
</option>
`)
: html`<option value="">No installed models</option>`;
})()}
</select>
<label class="mm-filter-label" for="mm-sort-mode-select">Sort</label>
<select class="mm-select" id="mm-sort-mode-select">
<option value="alphabetical" ${state.sortMode === "alphabetical" ? "selected" : ""}>Alphabetical</option>
<option value="release_date" ${state.sortMode === "release_date" ? "selected" : ""}>Release Date</option>
</select>
<div class="mm-filter-break"></div>
<label class="mm-filter-label" for="mm-community-filter-select">Community Favorite</label>
<select class="mm-select" id="mm-community-filter-select">
<option value="all" ${state.communityFavoriteFilter === "all" ? "selected" : ""}>All</option>
<option value="yes" ${state.communityFavoriteFilter === "yes" ? "selected" : ""}>Yes</option>
<option value="no" ${state.communityFavoriteFilter === "no" ? "selected" : ""}>No</option>
</select>
</div>
${() => state.loading ? html`<div class="mm-empty">Loading models...</div>` : ""}
${() => !state.loading ? html`
<div class="mm-list">
${(() => {
if (state.sortMode === "release_date") {
const models = getReleaseOrderedModels();
return models.length === 0
? html`<div class="mm-empty">No models available.</div>`
: models.map(model => renderModelRow(model));
}
const { grouped, seriesNames } = getSeriesGroups();
return seriesNames.length === 0
? html`<div class="mm-empty">No models available.</div>`
: seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName]));
})()}
</div>
` : ""}
</div>
`;
}
@@ -165,15 +165,16 @@ const api = {
}
}
let hasLoaded = false
if (!hasLoaded) {
hasLoaded = true
api.load()
}
export function TSKManager() {
return html`
<div class="tskkeys-wrapper tskkeys-offset-top">
let hasLoaded = false
export function TSKManager() {
if (!hasLoaded) {
hasLoaded = true
api.load()
}
return html`
<div class="tskkeys-wrapper tskkeys-offset-top">
<div class="tskkeys-container">
<div class="tskkeys-title">Toyota Security Key Manager</div>
@@ -7,7 +7,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
<link rel="manifest" href="/assets/manifest.json">
<link rel="manifest" href="/manifest.json">
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<meta name="theme-color" content="#8b6cc5" />
@@ -28,6 +28,7 @@
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
<link rel="stylesheet" href="/assets/components/tools/doors.css">
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
@@ -59,4 +60,4 @@
<div id="snackbar_wrapper"></div>
</body>
</html>
</html>
+373 -21
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
from flask import Flask, Response, jsonify, render_template, request, send_file, send_from_directory
from flask import Flask, Response, jsonify, make_response, render_template, request, send_file, send_from_directory
from io import BytesIO
from pathlib import Path
from werkzeug.utils import secure_filename
@@ -57,14 +57,69 @@ KEYS = {
TMUX_LOGS_PATH = Path("/data/tmux_logs")
MODEL_DOWNLOAD_PARAM = "ModelToDownload"
MODEL_DOWNLOAD_ALL_PARAM = "DownloadAllModels"
MODEL_DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress"
MODEL_CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
MODEL_SORT_MODE_PARAM = "ModelSortMode"
MODEL_USER_FAVORITES_PARAM = "UserFavorites"
def read_legacy_param_file(key, default_value=""):
try:
value_path = Path(params.get_param_path(key))
if value_path.is_file():
return value_path.read_text(encoding="utf-8").strip() or default_value
except Exception:
pass
return default_value
def write_legacy_param_file(key, value):
value_path = Path(params.get_param_path(key))
value_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = value_path.with_name(f".tmp_{value_path.name}")
tmp_path.write_text(str(value), encoding="utf-8")
os.replace(tmp_path, value_path)
def setup(app):
model_status_debug = {
"last_signature": None,
"last_log_time": 0.0,
"last_empty_catalog_log_time": 0.0,
}
@app.errorhandler(404)
def not_found(_):
return render_template("index.html")
response = make_response(render_template("index.html"))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@app.route("/", methods=["GET"])
def index():
return render_template("index.html")
response = make_response(render_template("index.html"))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@app.route("/manifest.json", methods=["GET"])
@app.route("/assets/manifest.json", methods=["GET"])
def manifest():
manifest_path = Path(app.static_folder) / "manifest.json"
if manifest_path.is_file():
return send_file(str(manifest_path), mimetype="application/manifest+json")
# Fallback so the browser doesn't keep logging noisy 404s.
return jsonify({
"name": "Galaxy",
"short_name": "Galaxy",
"display": "standalone",
"start_url": "/",
"background_color": "#000000",
"theme_color": "#8b6cc5",
"icons": [],
}), 200
@app.route("/api/doors_available", methods=["GET"])
def doors_available():
@@ -408,32 +463,329 @@ def setup(app):
@app.route("/api/models/installed", methods=["GET"])
def get_installed_models():
"""Returns only models with files present in /data/models/."""
import os
catalog = get_model_catalog()
installed = [{"value": model["value"], "label": model["label"]} for model in catalog if model["installed"]]
available = (params.get("AvailableModels", encoding="utf-8") or "").split(",")
names = (params.get("AvailableModelNames", encoding="utf-8") or "").split(",")
models_dir = "/data/models"
try:
on_disk = os.listdir(models_dir) if os.path.isdir(models_dir) else []
except Exception:
on_disk = []
installed = []
for i, key in enumerate(available):
if not key:
continue
if any(f.startswith(f"{key}.") or f.startswith(f"{key}_") for f in on_disk):
label = names[i] if i < len(names) else key
installed.append({"value": key, "label": label})
# Keep current model selectable even if local files are currently inconsistent.
current_model = params.get("Model", encoding="utf-8") or ""
if current_model and all(model["value"] != current_model for model in installed):
for model in catalog:
if model["value"] == current_model:
installed.append({"value": model["value"], "label": model["label"]})
break
return jsonify(installed), 200
@app.route("/api/models/catalog", methods=["GET"])
def get_models_catalog():
models = get_model_catalog()
return jsonify({
"models": models,
"currentModel": params.get("Model", encoding="utf-8") or "",
"summary": {
"installed": sum(1 for model in models if model["installed"]),
"missing": sum(1 for model in models if not model["installed"]),
"total": len(models),
},
}), 200
@app.route("/api/models/preferences", methods=["GET", "PUT"])
def get_or_set_models_preferences():
if request.method == "GET":
return jsonify({
"sortMode": read_legacy_param_file(MODEL_SORT_MODE_PARAM, "alphabetical"),
"userFavorites": [entry for entry in (params.get(MODEL_USER_FAVORITES_PARAM, encoding="utf-8") or "").split(",") if entry],
}), 200
data = request.get_json() or {}
changed = []
if "sortMode" in data:
sort_mode = str(data.get("sortMode") or "alphabetical").strip() or "alphabetical"
write_legacy_param_file(MODEL_SORT_MODE_PARAM, sort_mode)
changed.append("sort mode")
if "userFavorites" in data:
incoming = data.get("userFavorites")
if isinstance(incoming, list):
favorites = ",".join(entry.strip() for entry in incoming if str(entry).strip())
else:
favorites = ",".join(entry.strip() for entry in str(incoming or "").split(",") if entry.strip())
params.put(MODEL_USER_FAVORITES_PARAM, favorites)
changed.append("favorites")
if not changed:
return jsonify({"error": "No preferences provided."}), 400
return jsonify({"message": f"Updated model {' and '.join(changed)}."}), 200
@app.route("/api/models/status", methods=["GET"])
def get_models_status():
models = get_model_catalog()
model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""
download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
progress = params_memory.get(MODEL_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or ""
cancelling = params_memory.get_bool(MODEL_CANCEL_DOWNLOAD_PARAM)
downloading = bool(model_to_download) or download_all
current_model = params.get("Model", encoding="utf-8") or ""
sort_mode = read_legacy_param_file(MODEL_SORT_MODE_PARAM, "alphabetical")
terminal = progress in ("Downloaded!", "All models downloaded!") or bool(re.search(r"cancelled|exists|failed|offline|invalid|error", progress, re.IGNORECASE))
summary = {
"installed": sum(1 for model in models if model["installed"]),
"missing": sum(1 for model in models if not model["installed"]),
"total": len(models),
}
now = time.monotonic()
signature = (
summary["total"],
summary["installed"],
summary["missing"],
model_to_download,
download_all,
downloading,
cancelling,
progress,
current_model,
sort_mode,
terminal,
bool(params.get_bool("IsOnroad")),
)
if model_status_debug["last_signature"] != signature or now - model_status_debug["last_log_time"] >= 15:
print(
f"[ModelStatus] addr={request.remote_addr or 'unknown'} total={summary['total']} "
f"installed={summary['installed']} missing={summary['missing']} downloading={downloading} "
f"download_all={download_all} model='{model_to_download or '-'}' current='{current_model or '-'}' "
f"progress='{progress or 'Idle'}' cancelling={cancelling} onroad={params.get_bool('IsOnroad')} terminal={terminal}"
)
model_status_debug["last_signature"] = signature
model_status_debug["last_log_time"] = now
if summary["total"] == 0 and now - model_status_debug["last_empty_catalog_log_time"] >= 15:
available_models = params.get("AvailableModels", encoding="utf-8") or ""
available_names = params.get("AvailableModelNames", encoding="utf-8") or ""
available_models_count = len([item for item in available_models.split(",") if item.strip()])
available_names_count = len([item for item in available_names.split(",") if item.strip()])
print(
f"[ModelStatus] WARNING empty catalog available_models={available_models_count} "
f"available_names={available_names_count} raw_available_models='{available_models[:120]}'"
)
model_status_debug["last_empty_catalog_log_time"] = now
return jsonify({
"modelToDownload": model_to_download,
"downloadAll": download_all,
"downloading": downloading,
"cancelling": cancelling,
"progress": progress,
"isOnroad": params.get_bool("IsOnroad"),
"terminal": terminal,
"models": models,
"currentModel": current_model,
"summary": summary,
"sortMode": sort_mode,
}), 200
@app.route("/api/models/refresh_manifest", methods=["POST"])
def refresh_models_manifest():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot refresh model manifest while driving."}), 403
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
return jsonify({"error": "Cannot refresh model manifest while a download is in progress."}), 409
try:
from openpilot.frogpilot.assets.model_manager import ModelManager
manager = ModelManager()
manager.update_models(False)
except Exception as exception:
return jsonify({"error": f"Failed to refresh model manifest: {exception}"}), 500
return jsonify({"message": "Model manifest refreshed."}), 200
@app.route("/api/models/download", methods=["POST"])
def start_model_download():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot download models while driving."}), 403
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
return jsonify({"error": "A model download is already in progress."}), 409
data = request.get_json() or {}
model_key = (data.get("model") or "").strip()
if not model_key:
return jsonify({"error": "Missing model key."}), 400
catalog = {model["value"]: model for model in get_model_catalog()}
model = catalog.get(model_key)
if model is None:
return jsonify({"error": f"Unknown model '{model_key}'."}), 404
if model["installed"]:
return jsonify({"message": f"\"{model['label']}\" is already installed."}), 200
params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM)
params_memory.remove(MODEL_DOWNLOAD_ALL_PARAM)
params_memory.put(MODEL_DOWNLOAD_PARAM, model_key)
params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading...")
return jsonify({"message": f"Started downloading \"{model['label']}\"."}), 200
@app.route("/api/models/download_all", methods=["POST"])
def start_models_download_all():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot download models while driving."}), 403
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
return jsonify({"error": "A model download is already in progress."}), 409
missing_models = [model for model in get_model_catalog() if not model["installed"]]
if not missing_models:
return jsonify({"message": "All models are already installed."}), 200
params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM)
params_memory.remove(MODEL_DOWNLOAD_PARAM)
params_memory.put_bool(MODEL_DOWNLOAD_ALL_PARAM, True)
params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading...")
return jsonify({"message": f"Started downloading {len(missing_models)} model(s)."}), 200
@app.route("/api/models/cancel", methods=["POST"])
def cancel_model_download():
model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""
download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
if not model_to_download and not download_all:
return jsonify({"message": "No active model download to cancel."}), 200
params_memory.put_bool(MODEL_CANCEL_DOWNLOAD_PARAM, True)
return jsonify({"message": "Cancellation requested."}), 200
@app.route("/api/models/delete", methods=["POST"])
def delete_model_files():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot delete model files while driving."}), 403
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
return jsonify({"error": "Cannot delete model files while a download is in progress."}), 409
data = request.get_json() or {}
model_key = (data.get("model") or "").strip()
if not model_key:
return jsonify({"error": "Missing model key."}), 400
current_model = params.get("Model", encoding="utf-8") or ""
if model_key == current_model:
return jsonify({"error": "Cannot delete the currently active model."}), 409
catalog = {model["value"]: model for model in get_model_catalog()}
model = catalog.get(model_key)
if model is None:
return jsonify({"error": f"Unknown model '{model_key}'."}), 404
models_dir = Path("/data/models")
if not models_dir.is_dir():
return jsonify({"message": "No model directory exists yet."}), 200
deleted = []
for item in models_dir.iterdir():
name = item.name
is_match = (
name == f"{model_key}.thneed" or
name == f"{model_key}.pkl" or
name.startswith(f"{model_key}_")
)
if not is_match:
continue
try:
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink(missing_ok=True)
deleted.append(name)
except Exception as exception:
return jsonify({"error": f"Failed deleting '{name}': {exception}"}), 500
if not deleted:
return jsonify({"message": f"No files found for \"{model['label']}\"."}), 200
return jsonify({"message": f"Deleted {len(deleted)} file(s) for \"{model['label']}\"."}), 200
@app.route("/api/params_memory", methods=["GET"])
def get_param_memory():
return params_memory.get(request.args.get("key")) or "", 200
def is_model_installed(model_key, model_version, on_disk_files):
if f"{model_key}.thneed" in on_disk_files:
return True
if model_version in ("v8", "v9", "v10", "v11", "v12"):
required_files = {
f"{model_key}_driving_policy_tinygrad.pkl",
f"{model_key}_driving_vision_tinygrad.pkl",
f"{model_key}_driving_policy_metadata.pkl",
f"{model_key}_driving_vision_metadata.pkl",
}
if model_version == "v12":
required_files |= {
f"{model_key}_driving_off_policy_tinygrad.pkl",
f"{model_key}_driving_off_policy_metadata.pkl",
}
return required_files.issubset(on_disk_files)
if model_version == "v7":
return f"{model_key}.pkl" in on_disk_files
# Fallback for unknown versions
return any(file.startswith(f"{model_key}.") or file.startswith(f"{model_key}_") for file in on_disk_files)
def get_model_catalog():
available = [model.strip() for model in (params.get("AvailableModels", encoding="utf-8") or "").split(",")]
names = [name.strip() for name in (params.get("AvailableModelNames", encoding="utf-8") or "").split(",")]
series = [entry.strip() for entry in (params.get("AvailableModelSeries", encoding="utf-8") or "").split(",")]
versions = [entry.strip() for entry in (params.get("ModelVersions", encoding="utf-8") or "").split(",")]
released_dates = [entry.strip() for entry in (params.get("ModelReleasedDates", encoding="utf-8") or "").split(",")]
community_favorites = {entry.strip() for entry in (params.get("CommunityFavorites", encoding="utf-8") or "").split(",") if entry.strip()}
user_favorites = {entry.strip() for entry in (params.get(MODEL_USER_FAVORITES_PARAM, encoding="utf-8") or "").split(",") if entry.strip()}
models_dir = "/data/models"
try:
on_disk_files = set(os.listdir(models_dir)) if os.path.isdir(models_dir) else set()
except Exception:
on_disk_files = set()
models = []
for i, key in enumerate(available):
if not key:
continue
label = names[i] if i < len(names) and names[i] else key
model_version = versions[i] if i < len(versions) else ""
model_series = series[i] if i < len(series) and series[i] else "Custom Series"
released = released_dates[i] if i < len(released_dates) else ""
installed = is_model_installed(key, model_version, on_disk_files)
partial = not installed and any(file.startswith(f"{key}.") or file.startswith(f"{key}_") for file in on_disk_files)
models.append({
"value": key,
"label": label,
"series": model_series,
"version": model_version,
"released": released,
"installed": installed,
"partial": partial,
"communityFavorite": key in community_favorites,
"userFavorite": key in user_favorites,
})
models.sort(key=lambda model: (model["series"].lower(), model["label"].lower()))
return models
@app.route("/api/routes", methods=["GET"])
def list_routes():
def generate():