mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-11 02:33:51 +08:00
Mobile Friendly Galaxy
This commit is contained in:
@@ -484,6 +484,47 @@ launch_c4() {
|
||||
run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@"
|
||||
}
|
||||
|
||||
pick_free_galaxy_port() {
|
||||
"${ROOT_DIR}/.venv/bin/python3" - <<'PY'
|
||||
import socket
|
||||
|
||||
# Desktop ZMQ hashes replay service names into ports 8023-65535. Keep Galaxy
|
||||
# below that range so its HTTP server never steals a replay service port.
|
||||
for port in range(4600, 8023):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
sock.bind(("0.0.0.0", port))
|
||||
except OSError:
|
||||
continue
|
||||
print(port)
|
||||
raise SystemExit(0)
|
||||
|
||||
raise SystemExit("Unable to find a free local Galaxy port.")
|
||||
PY
|
||||
}
|
||||
|
||||
launch_galaxy() {
|
||||
sync_worktree
|
||||
ensure_host_python_extensions
|
||||
|
||||
local port
|
||||
port="$(pick_free_galaxy_port)"
|
||||
local galaxy_dir="${HOME}/.comma/starpilot/data/galaxy"
|
||||
|
||||
echo "Starting local Galaxy session on port ${port}..."
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
setup_build_env
|
||||
export_workdir_pythonpath
|
||||
export SP_GALAXY_DIR="${galaxy_dir}"
|
||||
export SP_GALAXY_HOST="0.0.0.0"
|
||||
export SP_GALAXY_PORT="${port}"
|
||||
export SP_GALAXY_DEBUG="${SP_GALAXY_DEBUG:-1}"
|
||||
export SP_GALAXY_RELOAD="${SP_GALAXY_RELOAD:-0}"
|
||||
exec "${WORK_DIR}/.venv/bin/python3" -m openpilot.starpilot.system.the_galaxy.the_galaxy
|
||||
)
|
||||
}
|
||||
|
||||
launch_onroad() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
|
||||
@@ -193,6 +193,19 @@ body {
|
||||
padding-left: var(--padding-lg);
|
||||
}
|
||||
|
||||
.embedded #sidebar,
|
||||
.embedded #sidebar_shell,
|
||||
.embedded #sidebarUnderlay {
|
||||
display: none !important;
|
||||
}
|
||||
.embedded #menu_button {
|
||||
display: none !important;
|
||||
}
|
||||
.embedded .content {
|
||||
margin-left: 0 !important;
|
||||
padding-left: var(--padding-lg);
|
||||
}
|
||||
|
||||
/* ――― Headings ――― */
|
||||
h1,
|
||||
h2,
|
||||
|
||||
@@ -4,8 +4,10 @@ import { hideSidebar } from "/assets/js/utils.js"
|
||||
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
|
||||
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15"
|
||||
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
|
||||
import { DoorControl } from "/assets/components/tools/doors.js"
|
||||
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
|
||||
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
|
||||
import { TSKManager } from "/assets/components/tools/tsk_manager.js"
|
||||
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
|
||||
import { Home } from "/assets/components/home/home.js"
|
||||
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
|
||||
@@ -70,12 +72,15 @@ function Root() {
|
||||
let routes = [
|
||||
createRoute("bluetooth", "/bluetooth", Bluetooth),
|
||||
createRoute("wheel_controls", "/wheel-controls", WheelControls),
|
||||
createRoute("doors", "/manage_doors", DoorControl),
|
||||
createRoute("tsk", "/manage_tsk", TSKManager),
|
||||
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
|
||||
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
|
||||
createRoute("galaxy", "/galaxy", GalaxyPairing),
|
||||
createRoute("navdestination", "/set_navigation_destination", NavDestination),
|
||||
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
|
||||
createRoute("root", "/", Home),
|
||||
createRoute("classicRoot", "/classic", Home),
|
||||
createRoute("routes", "/dashcam_routes", RouteRecordings),
|
||||
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
|
||||
createRoute("sentry", "/sentry", SentryMode),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en" id="htmlElement" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Galaxy">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="theme-color" content="#8b6cc5" />
|
||||
<link rel="manifest" href="/assets/mobile/manifest.json">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
|
||||
|
||||
<link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/mobile/css/material.css">
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"vue": "/assets/vendor/vue/vue.esm-browser.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<title>Galaxy</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="galaxy-bg" aria-hidden="true"></div>
|
||||
<div id="galaxy-app" v-cloak>
|
||||
<app-shell></app-shell>
|
||||
</div>
|
||||
<!-- Snackbar for messages -->
|
||||
<div id="snackbar_wrapper"></div>
|
||||
<script type="module" src="/assets/mobile/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,496 @@
|
||||
export const LAYOUT_URL = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1"
|
||||
|
||||
async function handle(res) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || data?.message || res.statusText || "Request failed")
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async postAction(endpoint) {
|
||||
const res = await fetch(endpoint, { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getOptions(endpoint) {
|
||||
const res = await fetch(endpoint)
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getLayout() {
|
||||
const res = await fetch(LAYOUT_URL, { cache: "no-store" })
|
||||
const data = await handle(res)
|
||||
return (data || [])
|
||||
.map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") }))
|
||||
.filter((section) => (section.params || []).length > 0)
|
||||
},
|
||||
|
||||
async getParams() {
|
||||
const res = await fetch("/api/params/all")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getDefaults() {
|
||||
const res = await fetch("/api/params/defaults")
|
||||
return res.ok ? handle(res) : {}
|
||||
},
|
||||
|
||||
async updateParam({ key, value, label }) {
|
||||
const body = { key, value }
|
||||
if (label) body.label = label
|
||||
const res = await fetch("/api/params", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getFlmWorkspace() {
|
||||
const res = await fetch("/api/flm/workspace", { cache: "no-store" })
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
|
||||
async getFavoritesSlots() {
|
||||
const res = await fetch("/api/favorites/slots", { cache: "no-store" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async saveFavoritesSlots(slots) {
|
||||
const res = await fetch("/api/favorites/slots", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slots }),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async activateFavoriteAction(key) {
|
||||
const res = await fetch("/api/favorites/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getDeviceStatus() {
|
||||
const res = await fetch("/api/device/status")
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
|
||||
async getStats() {
|
||||
const res = await fetch("/api/stats")
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
|
||||
|
||||
async getRoutesStream({ onProgress, onRoutes, signal } = {}) {
|
||||
const res = await fetch("/api/routes", { signal })
|
||||
if (!res.ok || !res.body) throw new Error(`Route request failed (${res.status})`)
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const events = buffer.split(/\r?\n\r?\n/)
|
||||
buffer = events.pop() || ""
|
||||
for (const event of events) {
|
||||
const lines = event.split(/\r?\n/).filter((l) => l.startsWith("data:"))
|
||||
if (!lines.length) continue
|
||||
try {
|
||||
const payload = JSON.parse(lines.map((l) => l.slice(5).trimStart()).join("\n"))
|
||||
if (Number.isFinite(payload.progress)) onProgress?.(payload.progress)
|
||||
onRoutes?.(Array.isArray(payload.routes) ? payload.routes : [])
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getRoute(name) {
|
||||
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`)
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async deleteRoute(name) {
|
||||
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async renameRoute(oldName, newName) {
|
||||
const res = await fetch("/api/routes/rename", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old: oldName, new: newName }),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async resetRouteName(name) {
|
||||
const res = await fetch("/api/routes/reset_name", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async setRoutePreserved(name, preserved) {
|
||||
const res = await fetch(`/api/routes/${encodeURIComponent(name)}/preserve`, { method: preserved ? "POST" : "DELETE" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async deleteAllRoutes(includePreserved) {
|
||||
const res = await fetch(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getRouteLogs(name) {
|
||||
const res = await fetch(`/api/routes/${encodeURIComponent(name)}/logs`)
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getScreenRecordings() {
|
||||
const res = await fetch("/api/screen_recordings/list")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async deleteScreenRecording(filename) {
|
||||
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async deleteAllScreenRecordings() {
|
||||
const res = await fetch("/api/screen_recordings/delete_all", { method: "DELETE" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async renameScreenRecording(oldName, newName) {
|
||||
const res = await fetch("/api/screen_recordings/rename", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old: oldName, new: newName }),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
|
||||
async getErrorLogs() {
|
||||
const res = await fetch("/api/error_logs", { headers: { Accept: "application/json" } })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getErrorLog(filename) {
|
||||
const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`)
|
||||
return res.text()
|
||||
},
|
||||
|
||||
async deleteErrorLog(filename) {
|
||||
const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`, { method: "DELETE" })
|
||||
return res.ok
|
||||
},
|
||||
|
||||
async deleteAllErrorLogs() {
|
||||
const res = await fetch("/api/error_logs/delete_all", { method: "DELETE" })
|
||||
return res.ok
|
||||
},
|
||||
|
||||
async getTmuxLogs() {
|
||||
const res = await fetch("/api/tmux_log/list")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async tmuxCapture() {
|
||||
const res = await fetch("/api/tmux_log/capture", { method: "POST" })
|
||||
return res.ok
|
||||
},
|
||||
|
||||
async tmuxSnapshot() {
|
||||
const res = await fetch("/api/tmux_log/snapshot")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async deleteTmuxLog(filename) {
|
||||
const res = await fetch(`/api/tmux_log/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
|
||||
return res.ok
|
||||
},
|
||||
|
||||
async deleteAllTmuxLogs() {
|
||||
const res = await fetch("/api/tmux_log/delete_all", { method: "DELETE" })
|
||||
return res.ok
|
||||
},
|
||||
|
||||
async renameTmuxLog(oldName, newName) {
|
||||
const res = await fetch(`/api/tmux_log/rename/${encodeURIComponent(oldName)}/${encodeURIComponent(newName)}`, { method: "PUT" })
|
||||
return res.ok
|
||||
},
|
||||
|
||||
async runTroubleshoot() {
|
||||
const res = await fetch("/api/troubleshoot", { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getTroubleshoot() {
|
||||
const res = await fetch("/api/troubleshoot")
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
|
||||
async resetTroubleshoot() {
|
||||
const res = await fetch("/api/troubleshoot/reset", { method: "POST" })
|
||||
return res.ok
|
||||
},
|
||||
|
||||
|
||||
async getWheelControlsStatus() {
|
||||
const res = await fetch("/api/wheel-controls/status", { cache: "no-store" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async wheelControlsOp(operation, body = {}) {
|
||||
const res = await fetch(`/api/wheel-controls/${operation}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getBluetoothStatus() {
|
||||
const res = await fetch("/api/bluetooth/status")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async bluetoothOp(operation, body = {}) {
|
||||
const res = await fetch(`/api/bluetooth/${operation}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async carFeaturesCheck(tool = "") {
|
||||
const query = tool ? `?tool=${encodeURIComponent(tool)}` : ""
|
||||
const res = await fetch(`/api/car_features_check${query}`)
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
|
||||
|
||||
async lateralManeuvers(action) {
|
||||
const res = await fetch(`/api/lateral_maneuvers/${action}`, { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async lateralManeuversStatus() {
|
||||
const res = await fetch("/api/lateral_maneuvers/status")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async longitudinalManeuvers(action) {
|
||||
const res = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async longitudinalManeuversStatus() {
|
||||
const res = await fetch("/api/longitudinal_maneuvers/status")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
|
||||
async getMapsStatus() {
|
||||
const res = await fetch("/api/maps/status")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getMapsCatalog() {
|
||||
const res = await fetch("/api/maps/catalog")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async mapsOp(operation, body = {}) {
|
||||
const res = await fetch(`/api/maps/${operation}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getNavigation() {
|
||||
const res = await fetch("/api/navigation")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async setNavigation(body) {
|
||||
const res = await fetch("/api/navigation", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getNavigationKeys() {
|
||||
const res = await fetch("/api/navigation_key")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async setNavigationKey(body) {
|
||||
const res = await fetch("/api/navigation_key", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async navigationFavorite(body) {
|
||||
const res = await fetch("/api/navigation/favorite", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
|
||||
async backupToggles() {
|
||||
const res = await fetch("/api/toggles/backup", { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data?.message || "Failed to create toggle backup.")
|
||||
}
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
async restoreToggles(data) {
|
||||
const res = await fetch("/api/toggles/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async resetTogglesDefault() {
|
||||
const res = await fetch("/api/toggles/reset_default", { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getUpdateBranches() {
|
||||
const res = await fetch("/api/update/branches")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getUpdateBranch() {
|
||||
const res = await fetch("/api/update/branch")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async setUpdateBranch(branch) {
|
||||
const res = await fetch("/api/update/branch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ branch }),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async updateFast() {
|
||||
const res = await fetch("/api/update/fast", { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getUpdateFastStatus() {
|
||||
const res = await fetch("/api/update/fast/status")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async updateRecover() {
|
||||
const res = await fetch("/api/update/recover", { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async updateRollback() {
|
||||
const res = await fetch("/api/update/rollback", { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async factoryReset() {
|
||||
const res = await fetch("/api/update/factory_reset", { method: "POST" })
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async getAgnosStatus() {
|
||||
const res = await fetch("/api/update/agnos_status")
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
|
||||
|
||||
async getVasmConfig() {
|
||||
const res = await fetch("/api/v_asm/config")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async setVasmConfig(body) {
|
||||
const res = await fetch("/api/v_asm/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async vasmSnapshot() {
|
||||
const res = await fetch("/api/v_asm/snapshot")
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
|
||||
async getPipConfig() {
|
||||
const res = await fetch("/api/pip_preview/config")
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async setPipConfig(body) {
|
||||
const res = await fetch("/api/pip_preview/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handle(res)
|
||||
},
|
||||
|
||||
async pipSnapshot() {
|
||||
const res = await fetch("/api/pip_preview/snapshot")
|
||||
return res.ok ? handle(res) : null
|
||||
},
|
||||
}
|
||||
|
||||
export function showSnackbar(message, level = "info") {
|
||||
const wrapper = document.getElementById("snackbar_wrapper")
|
||||
if (!wrapper) return
|
||||
for (const el of Array.from(wrapper.children)) {
|
||||
el.classList.remove("show")
|
||||
el.remove()
|
||||
}
|
||||
const el = document.createElement("div")
|
||||
el.className = "snackbar show"
|
||||
el.style.background = level === "error" ? "var(--error)" : "var(--color-confirm, #8b6cc5)"
|
||||
el.style.borderRadius = "var(--border-radius-base, 5px)"
|
||||
el.style.color = "var(--text-color, #fff)"
|
||||
el.style.margin = "0 auto var(--margin-base, 1rem)"
|
||||
el.style.padding = "var(--padding-base, 1rem)"
|
||||
el.style.textAlign = "center"
|
||||
el.textContent = message
|
||||
wrapper.appendChild(el)
|
||||
setTimeout(() => {
|
||||
el.classList.remove("show")
|
||||
setTimeout(() => el.remove(), 500)
|
||||
}, 2400)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createApp, h } from "vue"
|
||||
import { AppShell } from "./components/AppShell.js"
|
||||
import { Home } from "./views/Home.js"
|
||||
import { Settings } from "./views/Settings.js"
|
||||
import { Tools } from "./views/Tools.js"
|
||||
import { Recordings } from "./views/Recordings.js"
|
||||
import { Logs } from "./views/Logs.js"
|
||||
import { Tuning } from "./views/Tuning.js"
|
||||
import { Navigation } from "./views/Navigation.js"
|
||||
import { Vehicle } from "./views/Vehicle.js"
|
||||
import { SystemTools } from "./views/SystemTools.js"
|
||||
import { ToolEmbed } from "./views/ToolEmbed.js"
|
||||
import { store, initRouter, navigate } from "./store.js"
|
||||
import { showSnackbar } from "./api.js"
|
||||
|
||||
window.__galaxyVue = { createApp, h }
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
const data = event?.data
|
||||
if (!data || data.source !== "galaxy-embed" || typeof data.path !== "string") return
|
||||
const current = store.params.src || ""
|
||||
const target = data.path
|
||||
if (target === current || target === "/" + current) return
|
||||
navigate("/embed?src=" + encodeURIComponent(target))
|
||||
})
|
||||
|
||||
const VIEWS = {
|
||||
"/": Home,
|
||||
"/settings": Settings,
|
||||
"/tools": Tools,
|
||||
"/recordings": Recordings,
|
||||
"/logs": Logs,
|
||||
"/tuning": Tuning,
|
||||
"/navigation": Navigation,
|
||||
"/vehicle": Vehicle,
|
||||
"/system": SystemTools,
|
||||
"/embed": ToolEmbed,
|
||||
}
|
||||
|
||||
function resolveView(path) {
|
||||
if (path === "/embed" || path.startsWith("/embed/")) return ToolEmbed
|
||||
for (const [root, view] of Object.entries(VIEWS)) {
|
||||
if (path === root || (root !== "/" && path.startsWith(root + "/"))) return view
|
||||
}
|
||||
if (path === "/") return Home
|
||||
return ToolEmbed
|
||||
}
|
||||
|
||||
const app = createApp({
|
||||
name: "GalaxyApp",
|
||||
errorCaptured(err) {
|
||||
console.error("[galaxy-ui]", err)
|
||||
showSnackbar("Something went wrong: " + (err?.message || err), "error")
|
||||
return false
|
||||
},
|
||||
computed: {
|
||||
View() {
|
||||
return resolveView(store.route)
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return h(AppShell, null, {
|
||||
default: () => h(this.View),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
app.mount("#galaxy-app")
|
||||
|
||||
initRouter()
|
||||
|
||||
;(() => {
|
||||
const bg = document.getElementById("galaxy-bg")
|
||||
if (!bg) return
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const s = document.createElement("i")
|
||||
s.className = "galaxy-hero"
|
||||
s.style.left = (Math.random() * 100).toFixed(2) + "%"
|
||||
s.style.top = (Math.random() * 100).toFixed(2) + "%"
|
||||
s.style.animationDelay = (Math.random() * 4).toFixed(2) + "s"
|
||||
const size = Math.random() > 0.6 ? 3 : 2
|
||||
s.style.width = s.style.height = size + "px"
|
||||
bg.appendChild(s)
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,168 @@
|
||||
import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js"
|
||||
import { api } from "../api.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
|
||||
const NAV = {
|
||||
recordings: [
|
||||
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
|
||||
],
|
||||
tools: [
|
||||
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" },
|
||||
{ name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right" },
|
||||
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
|
||||
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" },
|
||||
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box" },
|
||||
{ name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video" },
|
||||
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
|
||||
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
|
||||
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
|
||||
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
|
||||
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
|
||||
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
|
||||
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
|
||||
],
|
||||
}
|
||||
|
||||
const BOTTOM_NAV = [
|
||||
{ name: "Home", link: "/", icon: "bi-house-fill" },
|
||||
{ name: "Settings", link: "/settings", icon: "bi-toggle-on" },
|
||||
{ name: "Tools", link: "/tools", icon: "bi-tools" },
|
||||
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
|
||||
]
|
||||
|
||||
export const AppShell = {
|
||||
name: "AppShell",
|
||||
data() {
|
||||
return { store, BOTTOM_NAV, NAV }
|
||||
},
|
||||
computed: {
|
||||
online() { return store.online },
|
||||
statusLabel() { return store.online ? store.deviceStatus : "Offline" },
|
||||
isLight() { return store.theme === "light" },
|
||||
drawerOpen: {
|
||||
get() { return store.drawerOpen },
|
||||
set(v) { store.drawerOpen = v },
|
||||
},
|
||||
activePath() { return store.route },
|
||||
search: {
|
||||
get() { return store.search },
|
||||
set(v) { store.search = v },
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"store.search"(q) {
|
||||
if (q && store.route !== "/settings" && !store.route.startsWith("/settings/")) {
|
||||
navigate("/settings")
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeDrawer() { store.drawerOpen = false },
|
||||
back() { goBack() },
|
||||
async refreshStatus() {
|
||||
try {
|
||||
const payload = await api.getDeviceStatus()
|
||||
if (!payload) throw new Error("no status")
|
||||
store.online = true
|
||||
store.deviceStatus = String(payload.status || "Parked")
|
||||
} catch (e) {
|
||||
store.online = false
|
||||
}
|
||||
},
|
||||
clearSearch() {
|
||||
store.search = ""
|
||||
this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() })
|
||||
},
|
||||
themeToggle() { toggleTheme() },
|
||||
navTo(link) {
|
||||
this.closeDrawer()
|
||||
navigate(toolHref(link))
|
||||
},
|
||||
bottomNavTo(item) {
|
||||
navigate(item.link)
|
||||
},
|
||||
isActive(link) {
|
||||
return this.activePath === link || (link !== "/" && this.activePath.startsWith(link))
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.statusPoll = usePolling(() => this.refreshStatus(), { interval: 5000 })
|
||||
this.statusPoll.start()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.statusPoll?.destroy()
|
||||
},
|
||||
template: `
|
||||
<div class="gx-app">
|
||||
<header class="gx-appbar">
|
||||
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" aria-label="Back" @click="back">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<div class="gx-appbar__pill">
|
||||
<button type="button" class="gx-icon-btn gx-menu-btn" aria-label="Menu" @click="store.drawerOpen = true">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
<span class="gx-appbar__title">Galaxy</span>
|
||||
<div class="gx-searchwrap">
|
||||
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search settings..."
|
||||
v-model="search" aria-label="Search settings" />
|
||||
<button v-if="search" type="button" class="gx-search-clear" aria-label="Clear search" @click="clearSearch">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gx-appbar__right">
|
||||
<span class="gx-status-pill">
|
||||
<span class="gx-status-dot" :class="online ? 'online' : 'offline'"></span>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="gx-icon-btn gx-theme-toggle" :aria-label="isLight ? 'Switch to dark mode' : 'Switch to light mode'"
|
||||
:title="isLight ? 'Dark mode' : 'Light mode'" @click="themeToggle">
|
||||
<i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<transition name="gx-fade">
|
||||
<div v-if="store.drawerOpen" class="gx-underlay" @click="closeDrawer"></div>
|
||||
</transition>
|
||||
<aside class="gx-drawer" :class="{ open: store.drawerOpen }">
|
||||
<div class="gx-drawer__header">
|
||||
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
|
||||
<span class="gx-drawer-title">Galaxy</span>
|
||||
</div>
|
||||
<div class="gx-nav-section">
|
||||
<div class="gx-nav-section__title">Main</div>
|
||||
<a class="gx-nav-item" :class="{ active: isActive('/') }" @click.prevent="navTo('/')">
|
||||
<i class="bi bi-house-fill"></i><span>Home</span>
|
||||
</a>
|
||||
<a class="gx-nav-item" :class="{ active: isActive('/settings') }" @click.prevent="navTo('/settings')">
|
||||
<i class="bi bi-toggle-on"></i><span>Toggles</span>
|
||||
</a>
|
||||
<a class="gx-nav-item" :class="{ active: isActive('/tools') }" @click.prevent="navTo('/tools')">
|
||||
<i class="bi bi-tools"></i><span>Tools</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-for="(links, section) in NAV" :key="section" class="gx-nav-section">
|
||||
<div class="gx-nav-section__title">{{ section }}</div>
|
||||
<a v-for="link in links" :key="link.link" class="gx-nav-item" @click.prevent="navTo(link.link)">
|
||||
<i class="bi" :class="link.icon"></i><span>{{ link.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="gx-content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<nav class="liquid-glass-nav">
|
||||
<button v-for="item in BOTTOM_NAV" :key="item.link" type="button"
|
||||
class="nav-item" :class="{ active: isActive(item.link) }"
|
||||
@click="bottomNavTo(item)">
|
||||
<i class="bi" :class="item.icon"></i>
|
||||
<span>{{ item.name }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
|
||||
function address(device) { return String(device.address || "").toUpperCase() }
|
||||
|
||||
export const BluetoothPanel = {
|
||||
name: "BluetoothPanel",
|
||||
data() {
|
||||
return {
|
||||
loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false,
|
||||
offroad: false, selectedAudio: "", pairingAddress: "", devices: [], prompt: null, pairValue: "", error: "",
|
||||
}
|
||||
},
|
||||
created() { this.poll = usePolling(() => this.refresh(), { interval: 2000 }); this.poll.start() },
|
||||
beforeUnmount() { this.poll?.destroy() },
|
||||
computed: {
|
||||
known() { return this.devices.filter((d) => d.paired || d.trusted || d.connected) },
|
||||
availableDevices() { return this.devices.filter((d) => !d.paired && !d.trusted && !d.connected) },
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
try {
|
||||
const p = await api.getBluetoothStatus()
|
||||
this.available = !!p.available
|
||||
this.enabled = !!p.enabled
|
||||
this.powered = !!p.powered
|
||||
this.discovering = !!p.discovering
|
||||
this.offroad = !!p.offroad
|
||||
this.selectedAudio = String(p.selected_audio || "")
|
||||
this.pairingAddress = String(p.pairing_address || "")
|
||||
this.devices = Array.isArray(p.devices) ? p.devices : []
|
||||
this.prompt = p.prompt || null
|
||||
this.error = p.error || ""
|
||||
} catch (e) {
|
||||
this.available = false
|
||||
this.error = e?.message || "Bluetooth service unavailable"
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async request(operation, body = {}) {
|
||||
if (this.busy) return
|
||||
this.busy = operation
|
||||
try {
|
||||
await api.bluetoothOp(operation, body)
|
||||
this.error = ""
|
||||
await this.refresh()
|
||||
} catch (e) {
|
||||
this.error = e?.message || "Bluetooth operation failed"
|
||||
} finally {
|
||||
this.busy = ""
|
||||
}
|
||||
},
|
||||
pair(d) { this.request("pair", { address: d.address }) },
|
||||
connect(d) { this.request(d.connected ? "disconnect" : "connect", { address: d.address }) },
|
||||
forget(d) { this.request("forget", { address: d.address }) },
|
||||
audio(d) { const isSel = this.selectedAudio.toUpperCase() === address(d); this.request("select_audio", { address: isSel ? "" : d.address }) },
|
||||
testAudio(d) { this.request("test_audio", { address: d.address }) },
|
||||
respondPairing(accepted) {
|
||||
const prompt = this.prompt
|
||||
if (!prompt || this.busy === "pairing_response") return
|
||||
if (accepted && (prompt.kind === "pin" || prompt.kind === "passkey") && !this.pairValue.trim()) {
|
||||
this.error = "Enter the value to continue pairing."
|
||||
return
|
||||
}
|
||||
this.request("pairing_response", { prompt_id: prompt.id, accepted, value: this.pairValue.trim() })
|
||||
},
|
||||
isPairing(d) { return !!this.pairingAddress && this.pairingAddress.toUpperCase() === address(d) },
|
||||
statusOf(d) {
|
||||
if (this.isPairing(d)) return "Pairing…"
|
||||
if (d.connected) {
|
||||
const audioSel = this.selectedAudio.toUpperCase() === address(d)
|
||||
return audioSel ? "Connected · Audio output" : "Connected"
|
||||
}
|
||||
return d.paired ? "Saved" : "Ready to pair"
|
||||
},
|
||||
offroadDisabled() { return !this.offroad || !!this.busy },
|
||||
needsPairValue() { return this.prompt && (this.prompt.kind === "pin" || this.prompt.kind === "passkey") },
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<div style="display:flex; align-items:center; gap:12px; justify-content:space-between;">
|
||||
<span>Bluetooth {{ enabled ? 'On' : 'Off' }}</span>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!available || offroadDisabled()" @click="request('power', { enabled: !enabled })">{{ enabled ? 'Turn Off' : 'Turn On' }}</button>
|
||||
</div>
|
||||
<p v-if="!offroad" style="color:var(--text-muted);">Scanning, pairing, and forgetting devices are available offroad only.</p>
|
||||
<p v-if="error" style="color:var(--error);">{{ error }}</p>
|
||||
|
||||
<div v-if="prompt" class="gx-card" style="margin:12px 0; background:var(--surface-variant);">
|
||||
<div class="gx-section__header"><i class="bi bi-shield-check"></i><span class="gx-section__title">Pairing request · {{ prompt.name }}</span></div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<p style="color:var(--text-muted);">{{ prompt.kind === 'confirmation' ? 'Confirm the pairing request.' : prompt.kind === 'authorization' ? 'Allow this device to connect?' : prompt.kind === 'pin' ? 'Enter the PIN supplied by the device.' : 'Enter the device passkey.' }}</p>
|
||||
<input v-if="needsPairValue" v-model="pairValue" class="gx-field" style="width:100%;" inputmode="numeric" placeholder="Value" />
|
||||
<div v-if="!prompt.display_only" style="display:flex; gap:8px; margin-top:8px;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy==='pairing_response'" @click="respondPairing(false)">Cancel</button>
|
||||
<button type="button" class="gx-btn" :disabled="busy==='pairing_response'" @click="respondPairing(true)">Allow</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; margin:12px 0;">
|
||||
<button type="button" class="gx-btn" :disabled="!offroad || !enabled || offroadDisabled()" @click="request(discovering ? 'stop_scan' : 'scan')">{{ discovering ? 'Searching…' : 'Search for Devices' }}</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="refresh"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:12px 0 8px;">My Devices</h4>
|
||||
<div v-if="!known.length" class="gx-empty" style="padding: var(--sp-2) 0;">No saved devices yet.</div>
|
||||
<div v-for="d in known" :key="d.address" class="gx-row" style="flex-wrap:wrap;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ d.name }} <span v-if="d.connected" class="gx-chip gx-chip--dev">Connected</span></span>
|
||||
<span class="gx-row__desc">{{ d.audio && d.controller ? 'Audio · Controller' : d.audio ? 'Audio' : d.controller ? 'Controller' : 'Bluetooth' }} · {{ statusOf(d) }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
||||
<button v-if="d.paired || d.connected" type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="connect(d)">{{ d.connected ? 'Disconnect' : 'Connect' }}</button>
|
||||
<button v-if="d.audio" type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="audio(d)">{{ selectedAudio.toUpperCase() === address(d) ? 'Stop Using for Audio' : 'Use for Audio' }}</button>
|
||||
<button v-if="d.audio && d.connected" type="button" class="gx-btn gx-btn--tonal" :disabled="offroadDisabled()" @click="testAudio(d)">Test Audio</button>
|
||||
<button v-if="d.paired" type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="offroadDisabled()" @click="forget(d)"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:12px 0 8px;">Available Devices</h4>
|
||||
<div v-if="!availableDevices.length" class="gx-empty" style="padding: var(--sp-2) 0;">{{ discovering ? 'Searching for nearby devices…' : 'No nearby devices found.' }}</div>
|
||||
<div v-for="d in availableDevices" :key="d.address" class="gx-row">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ d.name }}</span>
|
||||
<span class="gx-row__desc">{{ statusOf(d) }}</span>
|
||||
</div>
|
||||
<button type="button" class="gx-btn" :disabled="!offroad || !!busy || isPairing(d)" @click="pair(d)">{{ isPairing(d) ? 'Pairing…' : 'Pair' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { navigate } from "../store.js"
|
||||
|
||||
export const DevModeBanner = {
|
||||
name: "DevModeBanner",
|
||||
props: {
|
||||
hiddenCount: { type: Number, default: 0 },
|
||||
devModeOn: { type: Boolean, default: false },
|
||||
},
|
||||
computed: {
|
||||
visible() { return !this.devModeOn && this.hiddenCount > 0 },
|
||||
},
|
||||
methods: {
|
||||
unlock() { navigate("/settings/developer") },
|
||||
},
|
||||
template: `
|
||||
<div v-if="visible" class="gx-alert gx-alert--warn" role="status">
|
||||
<i class="bi bi-shield-lock gx-alert__icon"></i>
|
||||
<div class="gx-alert__body">
|
||||
<strong>{{ hiddenCount }} advanced setting{{ hiddenCount !== 1 ? "s" : "" }} hidden.</strong>
|
||||
<span>Advanced features are tucked away until you enable Developer Mode.</span>
|
||||
</div>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="unlock">Enable Developer Mode</button>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
|
||||
const FAVORITE_COUNT = 3
|
||||
const ACTION_PREFIX = "__starpilot_favorite_action__:"
|
||||
|
||||
function sortOptions(options) {
|
||||
return (options || []).slice().sort((a, b) =>
|
||||
String(a?.label || a?.key || "").localeCompare(String(b?.label || b?.key || ""), undefined, { numeric: true, sensitivity: "base" })
|
||||
)
|
||||
}
|
||||
|
||||
function defaultSlots() {
|
||||
return [0, 1, 2].map(() => ({ enabled: false, show_onroad: false, key: null, label: "" }))
|
||||
}
|
||||
|
||||
function normalizeSlots(slots) {
|
||||
const base = defaultSlots()
|
||||
if (!Array.isArray(slots)) return base
|
||||
slots.slice(0, FAVORITE_COUNT).forEach((slot, index) => {
|
||||
if (!slot || typeof slot !== "object") return
|
||||
const key = slot.key ? String(slot.key) : null
|
||||
base[index] = {
|
||||
enabled: !!slot.enabled,
|
||||
show_onroad: !!slot.show_onroad,
|
||||
key,
|
||||
label: key ? String(slot.label || key) : "",
|
||||
}
|
||||
})
|
||||
return base
|
||||
}
|
||||
|
||||
export const FavoritesEditor = {
|
||||
name: "FavoritesEditor",
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
saving: false,
|
||||
slots: [],
|
||||
options: [],
|
||||
values: {},
|
||||
filters: ["", "", ""],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
optionByKey() { return new Map(this.options.map((o) => [o.key, o])) },
|
||||
quickFavorites() {
|
||||
return this.slots
|
||||
.map((slot, index) => {
|
||||
const opt = this.optionByKey.get(slot.key || "")
|
||||
return { index, slot, opt, checked: !!(slot.key && opt && !!this.values[slot.key]) }
|
||||
})
|
||||
.filter((f) => f.slot.enabled && f.slot.key && f.opt)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
normalizeSlots,
|
||||
filteredOptions(index) {
|
||||
const q = (this.filters[index] || "").toLowerCase()
|
||||
return this.options.filter((o) =>
|
||||
!q || [o.label, o.key, o.section, o.description].some((v) => String(v || "").toLowerCase().includes(q))
|
||||
)
|
||||
},
|
||||
isActionSlot(slot) {
|
||||
const opt = this.optionByKey.get(slot.key || "")
|
||||
return String(slot.key || "").startsWith(ACTION_PREFIX) || !!opt?.action
|
||||
},
|
||||
async load() {
|
||||
this.loading = true
|
||||
try {
|
||||
const data = await api.getFavoritesSlots()
|
||||
this.options = sortOptions(data?.options)
|
||||
this.slots = normalizeSlots(data?.slots)
|
||||
this.values = { ...this.values, ...(data?.values || {}) }
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to load favorite slots.", "error")
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async saveSlots() {
|
||||
if (this.saving) return
|
||||
this.saving = true
|
||||
try {
|
||||
const data = await api.saveFavoritesSlots(this.slots)
|
||||
this.slots = normalizeSlots(data?.slots)
|
||||
if (Array.isArray(data?.options)) this.options = sortOptions(data.options)
|
||||
if (data?.values) this.values = { ...this.values, ...data.values }
|
||||
showSnackbar(data?.message || "Favorite slots saved.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Failed to save favorite slots.", "error")
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
updateSlot(index, patch) {
|
||||
const slots = this.slots.slice()
|
||||
slots[index] = { ...slots[index], ...patch }
|
||||
if (!slots[index].key) {
|
||||
slots[index].label = ""
|
||||
} else {
|
||||
slots[index].label = this.optionByKey.get(slots[index].key)?.label || slots[index].key
|
||||
}
|
||||
this.slots = slots
|
||||
this.saveSlots()
|
||||
},
|
||||
async toggleValue(key, checked) {
|
||||
const previous = this.values[key]
|
||||
this.values = { ...this.values, [key]: checked }
|
||||
try {
|
||||
const data = await api.updateParam({ key, value: checked })
|
||||
if (data?.updated && typeof data.updated === "object") this.values = { ...this.values, ...data.updated }
|
||||
showSnackbar(data?.message || `Parameter '${key}' updated.`)
|
||||
} catch (e) {
|
||||
this.values = { ...this.values, [key]: previous }
|
||||
showSnackbar(e?.message || "Network error — is the device reachable?", "error")
|
||||
}
|
||||
},
|
||||
async runAction(key) {
|
||||
try {
|
||||
const data = await api.activateFavoriteAction(key)
|
||||
showSnackbar(data?.message || "Favorite action sent.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Failed to send favorite action.", "error")
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() { await this.load() },
|
||||
template: `
|
||||
<div class="favorites-editor" style="display:grid; gap:var(--sp-3);">
|
||||
<div v-if="loading" class="gx-loading">Loading favorite slots...</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="quickFavorites.length" style="display:grid; gap:8px; grid-template-columns:repeat(auto-fit,minmax(180px,1fr));">
|
||||
<div v-for="f in quickFavorites" :key="f.slot.key"
|
||||
style="display:flex; flex-direction:column; gap:4px; padding:var(--sp-2) var(--sp-3); border:1px solid var(--outline-variant); border-radius:var(--radius-md);">
|
||||
<small style="color:var(--text-muted);">Favorite #{{ f.index + 1 }}</small>
|
||||
<strong>{{ f.opt.label || f.slot.key }}</strong>
|
||||
<span style="color:var(--text-muted); font-size:var(--fs-sm);">{{ f.opt.section || '' }}</span>
|
||||
<button v-if="isActionSlot(f.slot)" type="button" class="gx-btn" :disabled="saving" @click.prevent="runAction(f.slot.key)">
|
||||
Press
|
||||
</button>
|
||||
<label v-else class="gx-switch" style="align-self:flex-start;">
|
||||
<input type="checkbox" :checked="f.checked" :disabled="saving" @change="toggleValue(f.slot.key, $event.target.checked)" />
|
||||
<span class="gx-switch__track"></span>
|
||||
<span class="gx-switch__thumb"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(slot, index) in slots" :key="index" class="gx-card">
|
||||
<div class="gx-section__header">
|
||||
<span class="gx-section__title">Favorite #{{ index + 1 }}</span>
|
||||
<label class="gx-switch">
|
||||
<input type="checkbox" :checked="slot.enabled" :disabled="saving" @change="updateSlot(index, { enabled: $event.target.checked })" />
|
||||
<span class="gx-switch__track"></span>
|
||||
<span class="gx-switch__thumb"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:grid; gap:12px;">
|
||||
<label style="display:grid; gap:4px;">
|
||||
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Search</span>
|
||||
<input class="gx-field" type="search" :value="filters[index] || ''" :disabled="saving" placeholder="Search toggles..." @input="filters = filters.map((f,i)=> i===index ? $event.target.value : f)" />
|
||||
</label>
|
||||
<label style="display:grid; gap:4px;">
|
||||
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Toggle</span>
|
||||
<select class="gx-field" :value="slot.key || ''" :disabled="saving" @change="updateSlot(index, { key: $event.target.value || null })">
|
||||
<option value="">Select a toggle...</option>
|
||||
<option v-for="opt in filteredOptions(index)" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<span style="flex:1; font-size:var(--fs-sm);">On-Road Button (C4: tap invisible third)</span>
|
||||
<label class="gx-switch">
|
||||
<input type="checkbox" :checked="slot.show_onroad" :disabled="saving || !slot.enabled || !slot.key" @change="updateSlot(index, { show_onroad: $event.target.checked })" />
|
||||
<span class="gx-switch__track"></span>
|
||||
<span class="gx-switch__thumb"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Single source of truth for page-in-page embeds of the classic Galaxy SPA.
|
||||
// Every embed (ToolEmbed, Home dashboard, Tuning, Navigation maps/keys/speeds,
|
||||
// SystemTools toggles, Logs troubleshoot) renders through this component so the
|
||||
// classic page always gets: an `embedded=1` marker, sidebar-hide styles, and
|
||||
// optional navigation forwarding back to the mobile app.
|
||||
export const GalaxyEmbed = {
|
||||
name: "GalaxyEmbed",
|
||||
props: {
|
||||
src: { type: String, required: true },
|
||||
title: { type: String, default: "Tool" },
|
||||
// When true, injects a bridge that forwards the classic page's internal
|
||||
// navigation to the mobile app (which opens it as a proper ToolEmbed page).
|
||||
forwardNav: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
embedStyle: `
|
||||
#sidebar, #sidebar_shell, #sidebarUnderlay { display: none !important; }
|
||||
#menu_button { display: none !important; }
|
||||
.content { margin-left: 0 !important; }
|
||||
body { padding-left: 0 !important; }
|
||||
`,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
frameSrc() {
|
||||
const base = this.src
|
||||
return base + (base.includes("?") ? "&" : "?") + "embedded=1"
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
injectEmbedStyles() {
|
||||
const frame = this.$refs.frame
|
||||
if (!frame) return
|
||||
try {
|
||||
const doc = frame.contentDocument || frame.contentWindow?.document
|
||||
if (!doc || !doc.head) return
|
||||
let style = doc.getElementById("gx-embed-hide-sidebar")
|
||||
if (!style) {
|
||||
style = doc.createElement("style")
|
||||
style.id = "gx-embed-hide-sidebar"
|
||||
doc.head.appendChild(style)
|
||||
}
|
||||
style.textContent = this.embedStyle
|
||||
|
||||
if (!this.forwardNav) return
|
||||
let bridge = doc.getElementById("gx-embed-nav-bridge")
|
||||
if (!bridge) {
|
||||
bridge = doc.createElement("script")
|
||||
bridge.id = "gx-embed-nav-bridge"
|
||||
bridge.textContent = `(() => {
|
||||
const post = () => {
|
||||
if (window.self === window.top) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.delete("embedded")
|
||||
const qs = params.toString()
|
||||
window.parent.postMessage({ source: "galaxy-embed", path: window.location.pathname + (qs ? "?" + qs : "") }, "*")
|
||||
}
|
||||
const patch = (type) => {
|
||||
const orig = history[type]
|
||||
history[type] = function () { const r = orig.apply(this, arguments); post(); return r }
|
||||
}
|
||||
patch("pushState")
|
||||
patch("replaceState")
|
||||
window.addEventListener("popstate", post)
|
||||
})()`
|
||||
doc.head.appendChild(bridge)
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.frame?.addEventListener("load", () => this.injectEmbedStyles())
|
||||
},
|
||||
template: `
|
||||
<div class="gx-embed">
|
||||
<iframe ref="frame" :src="frameSrc" class="gx-embed__frame" frameborder="0"
|
||||
allow="clipboard-read; clipboard-write" :title="title"></iframe>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export const GalaxyModal = {
|
||||
name: "GalaxyModal",
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: "Are you sure?" },
|
||||
message: { type: String, default: "" },
|
||||
confirmLabel: { type: String, default: "Confirm" },
|
||||
cancelLabel: { type: String, default: "Cancel" },
|
||||
danger: { type: Boolean, default: false },
|
||||
sheet: { type: Boolean, default: true },
|
||||
},
|
||||
emits: ["update:modelValue", "confirm", "cancel"],
|
||||
methods: {
|
||||
close() { this.$emit("update:modelValue", false) },
|
||||
cancel() { this.close(); this.$emit("cancel") },
|
||||
confirm() { this.$emit("confirm"); this.close() },
|
||||
},
|
||||
template: `
|
||||
<transition name="gx-fade">
|
||||
<div v-if="modelValue" class="gx-scrim" @click.self="cancel">
|
||||
<transition name="gx-slide" appear>
|
||||
<div class="gx-sheet" role="dialog" :aria-label="title">
|
||||
<h3 class="gx-sheet__title">{{ title }}</h3>
|
||||
<p v-if="message" style="color: var(--text-muted); line-height: 1.5;">{{ message }}</p>
|
||||
<div class="gx-dialog__actions">
|
||||
<button type="button" class="gx-btn gx-btn--text" @click="cancel">{{ cancelLabel }}</button>
|
||||
<button type="button" class="gx-btn" :style="danger ? 'background: var(--error); color: var(--on-error);' : ''" @click="confirm">{{ confirmLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
`,
|
||||
}
|
||||
|
||||
export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement("div")
|
||||
document.body.appendChild(host)
|
||||
const { createApp, h } = window.__galaxyVue
|
||||
let instance
|
||||
const app = createApp({
|
||||
render() {
|
||||
return h(GalaxyModal, {
|
||||
modelValue: true,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
danger,
|
||||
"onUpdate:modelValue": (v) => { if (!v) teardown() },
|
||||
onConfirm: () => { teardown(); resolve(true) },
|
||||
onCancel: () => { teardown(); resolve(false) },
|
||||
})
|
||||
},
|
||||
})
|
||||
const teardown = () => {
|
||||
app.unmount()
|
||||
host.remove()
|
||||
resolve(false)
|
||||
}
|
||||
instance = app.mount(host)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const GalaxySection = {
|
||||
name: "GalaxySection",
|
||||
props: {
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, default: "bi-toggles" },
|
||||
count: { type: [Number, String], default: "" },
|
||||
defaultOpen: { type: Boolean, default: true },
|
||||
},
|
||||
data() { return { open: this.defaultOpen } },
|
||||
template: `
|
||||
<section class="gx-card">
|
||||
<div class="gx-section__header" role="button" @click="open = !open">
|
||||
<i class="bi" :class="icon"></i>
|
||||
<span class="gx-section__title">{{ title }}</span>
|
||||
<span v-if="count !== ''" class="gx-section__count">{{ count }}</span>
|
||||
<i class="bi bi-chevron-down gx-chevron" :class="{ open }"></i>
|
||||
</div>
|
||||
<transition name="gx-collapse">
|
||||
<div v-show="open" class="gx-section__body">
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import {
|
||||
coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault,
|
||||
normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep,
|
||||
stepPrecision,
|
||||
} from "../params.js"
|
||||
import { FavoritesEditor } from "./FavoritesEditor.js"
|
||||
|
||||
export const GalaxyToggleCard = {
|
||||
name: "GalaxyToggleCard",
|
||||
components: { FavoritesEditor },
|
||||
props: {
|
||||
param: { type: Object, required: true },
|
||||
value: { default: undefined },
|
||||
locked: { type: Boolean, default: false },
|
||||
manageable: { type: Boolean, default: false },
|
||||
manageOpen: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["change", "manage"],
|
||||
data() {
|
||||
return {
|
||||
updating: false,
|
||||
endpointOptions: null,
|
||||
optionsLoaded: false,
|
||||
endpointLoading: false,
|
||||
preview: undefined,
|
||||
interacting: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bounds() { return numericBounds(this.param, {}) },
|
||||
precision() { return stepPrecision(this.bounds.step, this.param.precision) },
|
||||
epsilon() { return numericEpsilon(this.precision) },
|
||||
isSlider() { return this.isNumeric },
|
||||
isNumeric() { return this.param.ui_type === "numeric" },
|
||||
isReadout() { return this.param.ui_type === "readout" },
|
||||
isGroup() { return this.param.ui_type === "group" },
|
||||
currentValue() { return this.preview !== undefined ? this.preview : this.value },
|
||||
displayValue() {
|
||||
if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock"
|
||||
if (this.isReadout) return formatReadoutValue(this.param, this.value)
|
||||
return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".."
|
||||
},
|
||||
sliderDisplay() {
|
||||
return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".."
|
||||
},
|
||||
isColor() { return this.param.ui_type === "color" },
|
||||
isAction() { return this.param.ui_type === "action" },
|
||||
isFavorites() { return this.param.ui_type === "favorites" },
|
||||
isText() { return this.param.ui_type === "text" },
|
||||
isSelect() { return this.param.ui_type === "dropdown" },
|
||||
isSwitch() { return !this.isNumeric && !this.isColor && !this.isAction && !this.isFavorites && !this.isGroup && !this.isReadout && !this.isSelect && !this.isText },
|
||||
selectOptions() {
|
||||
return this.param.options || this.endpointOptions || []
|
||||
},
|
||||
optionsLoading() {
|
||||
return Boolean(this.param.options_endpoint) && this.endpointLoading
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
normalizeHexColor,
|
||||
getColorDefault,
|
||||
coerce(v) { return coerceValueByType(v, this.param.data_type) },
|
||||
labelOf(el) { return el?.options?.[el.selectedIndex]?.textContent || "" },
|
||||
rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) },
|
||||
async commit(nextValue) {
|
||||
const prev = this.value
|
||||
const label = this.lastLabel || ""
|
||||
this.$emit("change", { key: this.param.key, value: nextValue })
|
||||
this.updating = true
|
||||
try {
|
||||
const data = await api.updateParam({ key: this.param.key, value: nextValue, label })
|
||||
const updated = data?.updated && typeof data.updated === "object" ? data.updated : {}
|
||||
if (Object.prototype.hasOwnProperty.call(updated, this.param.key)) {
|
||||
this.$emit("change", { key: this.param.key, value: updated[this.param.key], ...updated })
|
||||
}
|
||||
showSnackbar(data?.message || `Parameter '${this.param.key}' updated.`)
|
||||
} catch (err) {
|
||||
this.rollback(prev)
|
||||
showSnackbar(err?.message || "Network error — is the device reachable?", "error")
|
||||
} finally {
|
||||
this.updating = false
|
||||
}
|
||||
},
|
||||
onSwitch(e) {
|
||||
if (!this.locked) this.commit(!!e.target.checked)
|
||||
else e.target.checked = !!this.value
|
||||
},
|
||||
onSelect(e) {
|
||||
if (this.locked) { e.target.value = String(this.value ?? "") ; return }
|
||||
this.lastLabel = e.target.options?.[e.target.selectedIndex]?.textContent || ""
|
||||
this.commit(this.coerce(e.target.value))
|
||||
},
|
||||
onText(e) {
|
||||
if (!this.locked) this.commit(this.coerce(e.target.value))
|
||||
},
|
||||
onColor(e) {
|
||||
if (this.locked) return
|
||||
this.commit(normalizeHexColor(e.target.value) || getColorDefault(this.param))
|
||||
},
|
||||
beginInteract() { this.interacting = true },
|
||||
flushSlider(rawValue) {
|
||||
const next = snapNumericToBoundsAndStep(rawValue, this.bounds, this.precision)
|
||||
this.preview = undefined
|
||||
if (next === null) return
|
||||
const current = this.snap(this.value)
|
||||
if (Math.abs(next - current) <= this.epsilon) return
|
||||
this.commit(next)
|
||||
},
|
||||
onSliderInput(e) {
|
||||
this.beginInteract()
|
||||
this.preview = Number(e.target.value)
|
||||
},
|
||||
onSliderCommit(e) {
|
||||
this.interacting = false
|
||||
this.flushSlider(e.target.value)
|
||||
},
|
||||
onSliderBlur(e) {
|
||||
if (this.interacting) this.onSliderCommit(e)
|
||||
},
|
||||
snap(raw) {
|
||||
return snapNumericToBoundsAndStep(raw, this.bounds, this.precision)
|
||||
},
|
||||
async resetToDefault() {
|
||||
const defaults = await api.getDefaults()
|
||||
const stockKey = `${this.param.key}Stock`
|
||||
const stock = defaults?.[stockKey]
|
||||
const raw = stock !== undefined && stock !== null ? stock : defaults?.[this.param.key]
|
||||
const next = this.snap(raw)
|
||||
if (next === null) { showSnackbar("No default value available for this setting.", "error"); return }
|
||||
if (Math.abs(next - (this.snap(this.value) ?? 0)) <= this.epsilon) return
|
||||
this.commit(next)
|
||||
},
|
||||
resetColor() {
|
||||
if (normalizeHexColor(this.value) === "") return
|
||||
this.commit("stock")
|
||||
},
|
||||
runAction() {
|
||||
if (this.locked || this.updating) return
|
||||
this.updating = true
|
||||
api.postAction(String(this.param.action_endpoint || ""))
|
||||
.then((data) => {
|
||||
if (!data?.error) {
|
||||
showSnackbar(data?.message || `${this.param.label || this.param.key} completed.`)
|
||||
if (data?.updated && typeof data.updated === "object") this.$emit("change", data.updated)
|
||||
} else {
|
||||
showSnackbar(data.error, "error")
|
||||
}
|
||||
})
|
||||
.catch(() => showSnackbar(`${this.param.label || this.param.key} failed.`, "error"))
|
||||
.finally(() => { this.updating = false })
|
||||
},
|
||||
loadEndpointOptions() {
|
||||
if (!this.param.options_endpoint || this.optionsLoaded) return
|
||||
this.optionsLoaded = true
|
||||
this.endpointLoading = true
|
||||
api.getOptions(this.param.options_endpoint)
|
||||
.then((opts) => { this.endpointOptions = opts })
|
||||
.catch(() => { this.endpointOptions = [] })
|
||||
.finally(() => { this.endpointLoading = false })
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.param.options_endpoint) this.loadEndpointOptions()
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ param.label }}
|
||||
<span v-if="param.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
|
||||
</span>
|
||||
<span v-if="param.description" class="gx-row__desc">{{ param.description }}</span>
|
||||
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div>
|
||||
</div>
|
||||
|
||||
<label v-if="isSwitch" class="gx-switch">
|
||||
<input type="checkbox" :checked="!!value" :disabled="locked || updating" @change="onSwitch" />
|
||||
<span class="gx-switch__track"></span>
|
||||
<span class="gx-switch__thumb"></span>
|
||||
</label>
|
||||
|
||||
<div v-else-if="isFavorites" style="width:100%;">
|
||||
<FavoritesEditor />
|
||||
</div>
|
||||
|
||||
<div v-else-if="isSlider" class="gx-slider-row">
|
||||
<span class="gx-row__value" style="min-width:64px; text-align:right;">{{ sliderDisplay }}</span>
|
||||
<input type="range" class="gx-slider" :min="bounds.min" :max="bounds.max" :step="bounds.step"
|
||||
:value="currentValue" :disabled="locked || updating"
|
||||
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
|
||||
@touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" />
|
||||
<button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button>
|
||||
</div>
|
||||
|
||||
<select v-else-if="isSelect" class="gx-field" :disabled="locked || updating" :value="String(value ?? '')" @change="onSelect">
|
||||
<option v-if="optionsLoading" value="">Loading...</option>
|
||||
<option v-else-if="!selectOptions.length" value="">No options available</option>
|
||||
<option v-for="opt in selectOptions" :key="String(opt.value)" :value="String(opt.value)">{{ opt.label }}</option>
|
||||
</select>
|
||||
|
||||
<input v-else-if="isText" class="gx-field" :type="param.input_type || 'text'" :value="value ?? ''"
|
||||
:placeholder="param.placeholder || ''" :disabled="locked || updating" @change="onText" />
|
||||
|
||||
<div v-else-if="isColor" style="display:flex; align-items:center; gap:8px;">
|
||||
<span class="gx-row__value">{{ displayValue }}</span>
|
||||
<input type="color" class="gx-color" :value="normalizeHexColor(value) || getColorDefault(param)"
|
||||
:disabled="locked || updating" @change="onColor" />
|
||||
<button class="gx-slider-reset" :disabled="locked || updating || !normalizeHexColor(value)" @click="resetColor">Stock</button>
|
||||
</div>
|
||||
|
||||
<span v-else-if="isReadout" class="gx-row__value">{{ displayValue }}</span>
|
||||
|
||||
<button v-else-if="isAction" class="gx-btn" :disabled="locked || updating" @click="runAction">
|
||||
{{ updating ? "Working..." : (param.action_label || "Run") }}
|
||||
</button>
|
||||
|
||||
<button v-else-if="isGroup" class="gx-btn gx-btn--tonal" @click="$emit('manage', param.key)">Manage</button>
|
||||
</div>
|
||||
<button v-if="manageable" type="button" class="gx-manage-btn" @click="$emit('manage', param.key)">
|
||||
{{ manageOpen ? "Close" : "Manage" }}
|
||||
<i class="bi" :class="manageOpen ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { usePolling, formatAgeSeconds } from "../composables.js"
|
||||
import { showSnackbar } from "../api.js"
|
||||
|
||||
function safeNumber(value, fallback = 0) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export const ManeuverCard = {
|
||||
name: "ManeuverCard",
|
||||
props: {
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, default: "bi-sign-turn-right" },
|
||||
intro: { type: String, default: "" },
|
||||
start: { type: Function, required: true },
|
||||
stop: { type: Function, required: true },
|
||||
status: { type: Function, required: true },
|
||||
interval: { type: Number, default: 3000 },
|
||||
},
|
||||
data() {
|
||||
return { loading: true, busy: false, data: null }
|
||||
},
|
||||
created() {
|
||||
this.poll = usePolling(() => this.refreshStatus(), { interval: this.interval })
|
||||
this.poll.start()
|
||||
},
|
||||
beforeUnmount() { this.poll?.destroy() },
|
||||
methods: {
|
||||
formatAgeSeconds,
|
||||
safeNumber,
|
||||
async refreshStatus() {
|
||||
try {
|
||||
const payload = await this.status()
|
||||
this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : null
|
||||
this.loading = false
|
||||
} catch (e) {
|
||||
this.loading = false
|
||||
throw e
|
||||
}
|
||||
},
|
||||
async run(action) {
|
||||
if (this.busy) return
|
||||
this.busy = true
|
||||
try {
|
||||
const fn = action === "start" ? this.start : this.stop
|
||||
const payload = await fn()
|
||||
this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : this.data
|
||||
showSnackbar(payload?.message || "Action complete.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Action failed.", "error")
|
||||
} finally {
|
||||
this.busy = false
|
||||
}
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<section class="gx-card">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi" :class="icon"></i>
|
||||
<span class="gx-section__title">{{ title }}</span>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<p style="color: var(--text-muted); line-height:1.5;">{{ intro }}</p>
|
||||
<div style="display:flex; gap:8px; margin: 12px 0;">
|
||||
<button type="button" class="gx-btn" :disabled="busy" @click="run('start')">Start / Arm</button>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="busy" @click="run('stop')">Stop</button>
|
||||
</div>
|
||||
<div v-if="loading" class="gx-loading">Loading status...</div>
|
||||
<dl v-else-if="data" class="gx-stat-grid" style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
|
||||
<div><strong>Mode</strong><span>{{ data.modeEnabled ? 'Yes' : 'No' }}</span></div>
|
||||
<div><strong>State</strong><span>{{ data.state || 'idle' }}</span></div>
|
||||
<div><strong>Onroad</strong><span>{{ data.isOnroad ? 'Yes' : 'No' }}</span></div>
|
||||
<div><strong>Engaged</strong><span>{{ data.isEngaged ? 'Yes' : 'No' }}</span></div>
|
||||
<div><strong>Phase</strong><span>{{ data.phase || 'n/a' }}</span></div>
|
||||
<div><strong>Step</strong><span>{{ safeNumber(data.stepIndex, 0) }}/{{ safeNumber(data.stepTotal, 0) }}</span></div>
|
||||
<div><strong>Run</strong><span>{{ safeNumber(data.runIndex, 0) }}/{{ safeNumber(data.runTotal, 0) }}</span></div>
|
||||
<div><strong>Updated</strong><span>{{ formatAgeSeconds(data.updatedAgeSec) }}</span></div>
|
||||
<div style="grid-column:1/-1;"><strong>Current</strong><span>{{ data.maneuver || 'n/a' }}</span></div>
|
||||
</dl>
|
||||
<div v-if="data && data.history?.length" class="gx-card" style="margin-top:12px;">
|
||||
<div class="gx-section__header"><i class="bi bi-list-ol"></i><span class="gx-section__title">Progress Chain</span></div>
|
||||
<ol style="margin:0; padding: var(--sp-3) var(--sp-4);">
|
||||
<li v-for="line in [...data.history].reverse()" :key="line">{{ line }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { api } from "../api.js"
|
||||
import { isSettingVisible, slugifySectionName, applyParamChange } from "../params.js"
|
||||
import { SettingTree } from "./SettingTree.js"
|
||||
import { GalaxySection } from "./GalaxySection.js"
|
||||
|
||||
export const ParamSections = {
|
||||
name: "ParamSections",
|
||||
components: { SettingTree, GalaxySection },
|
||||
props: {
|
||||
sectionNames: { type: Array, required: true },
|
||||
search: { type: String, default: "" },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
layout: [],
|
||||
values: {},
|
||||
expanded: {},
|
||||
loading: true,
|
||||
error: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sections() {
|
||||
return this.layout
|
||||
.filter((s) => this.sectionNames.includes(s.name))
|
||||
.map((s) => ({
|
||||
...s,
|
||||
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values) && this.matches(p)),
|
||||
slug: slugifySectionName(s.name),
|
||||
}))
|
||||
.filter((s) => s.params.length > 0)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
matches(p) {
|
||||
if (!this.search) return true
|
||||
const q = this.search.toLowerCase()
|
||||
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
const [layout, values] = await Promise.all([api.getLayout(), api.getParams()])
|
||||
this.layout = layout
|
||||
this.values = values || {}
|
||||
} catch (e) {
|
||||
this.error = e?.message || "Failed to load settings."
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
onParamChange(patch) { this.values = applyParamChange(this.values, patch) },
|
||||
toggleManage(key) { this.expanded = { ...this.expanded, [key]: !this.expanded[key] } },
|
||||
},
|
||||
async mounted() { await this.load() },
|
||||
template: `
|
||||
<div>
|
||||
<div v-if="loading" class="gx-loading">Loading configuration...</div>
|
||||
<div v-if="error" class="gx-empty" style="color: var(--error);">{{ error }}</div>
|
||||
<GalaxySection v-for="s in sections" :key="s.slug" :title="s.name" :icon="s.icon || 'bi-toggles'" :count="s.params.length">
|
||||
<SettingTree :params="s.params" :parent-key="null" :values="values" :expanded="expanded"
|
||||
@change="onParamChange" @manage="toggleManage" />
|
||||
<div v-if="!s.params.length" class="gx-empty">No settings in this section.</div>
|
||||
</GalaxySection>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { GalaxyToggleCard } from "./GalaxyToggleCard.js"
|
||||
import { hasChildParams, isGroupParam, isParamEnabledForChildren } from "../params.js"
|
||||
|
||||
export const SettingTree = {
|
||||
name: "SettingTree",
|
||||
components: { GalaxyToggleCard },
|
||||
props: {
|
||||
params: { type: Array, required: true },
|
||||
parentKey: { default: null },
|
||||
depth: { type: Number, default: 0 },
|
||||
values: { type: Object, required: true },
|
||||
expanded: { type: Object, default: () => ({}) },
|
||||
lockReason: { type: Function, default: () => "" },
|
||||
},
|
||||
emits: ["change", "manage"],
|
||||
computed: {
|
||||
children() {
|
||||
return this.params.filter((p) => (p.parent_key || null) === this.parentKey)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
enabledForChildren(p) { return isParamEnabledForChildren(p, this.values) },
|
||||
isParent(p) { return hasChildParams(this.params, p.key) },
|
||||
isGroup(p) { return isGroupParam(p) },
|
||||
isExpanded(p) { return !!this.expanded[p.key] },
|
||||
showChildren(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
|
||||
manageable(p) { return this.isParent(p) && this.enabledForChildren(p) },
|
||||
manageOpen(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
|
||||
},
|
||||
template: `
|
||||
<template v-for="p in children" :key="p.key">
|
||||
<div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
|
||||
<GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
|
||||
:manageable="manageable(p)" :manage-open="manageOpen(p)"
|
||||
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
|
||||
</div>
|
||||
<transition name="gx-collapse">
|
||||
<div v-if="showChildren(p)" class="gx-tree-children">
|
||||
<SettingTree :params="params" :parent-key="p.key" :depth="depth + 1"
|
||||
:values="values" :expanded="expanded" :lock-reason="lockReason"
|
||||
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { api } from "../api.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
|
||||
const FAVORITE_SLOT_COUNT = 3
|
||||
|
||||
export const WheelControls = {
|
||||
name: "WheelControls",
|
||||
data() {
|
||||
return {
|
||||
loading: true, busy: "", available: false, offroad: false, learning: false,
|
||||
devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [],
|
||||
joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false,
|
||||
lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "",
|
||||
}
|
||||
},
|
||||
created() { this.poll = usePolling(() => this.refresh(), { interval: 750 }); this.poll.start() },
|
||||
beforeUnmount() { this.poll?.destroy() },
|
||||
methods: {
|
||||
async refresh() {
|
||||
try {
|
||||
const p = await api.getWheelControlsStatus()
|
||||
this.available = !!p.available
|
||||
this.offroad = !!p.offroad
|
||||
this.learning = !!p.learning
|
||||
this.devices = Array.isArray(p.devices) ? p.devices : []
|
||||
this.mappings = Array.isArray(p.mappings) ? p.mappings : []
|
||||
this.slots = Array.isArray(p.slots) ? p.slots : []
|
||||
this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : []
|
||||
this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : []
|
||||
this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : ""
|
||||
this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null
|
||||
this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0
|
||||
this.testing = !!p.testing
|
||||
this.lastTested = p.last_tested && typeof p.last_tested === "object" ? p.last_tested : null
|
||||
this.speedUnit = String(p.speed_unit || "mph")
|
||||
this.speedMinimum = Number.isFinite(Number(p.speed_minimum)) ? Number(p.speed_minimum) : 0
|
||||
this.speedMaximum = Number.isFinite(Number(p.speed_maximum)) ? Number(p.speed_maximum) : 0
|
||||
this.error = ""
|
||||
} catch (e) {
|
||||
this.available = false
|
||||
this.error = e?.message || "Wheel controls are unavailable"
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async request(operation, body = {}) {
|
||||
if (this.busy) return
|
||||
this.busy = operation
|
||||
try {
|
||||
await api.wheelControlsOp(operation, body)
|
||||
this.error = ""
|
||||
await this.refresh()
|
||||
} catch (e) {
|
||||
this.error = e?.message || "Wheel control operation failed"
|
||||
} finally {
|
||||
this.busy = ""
|
||||
}
|
||||
},
|
||||
mappingsOf(slot) { return this.mappings.filter((m) => m.slot === slot) },
|
||||
actionSlotIndex(i) { return FAVORITE_SLOT_COUNT + i },
|
||||
learn(slot) { this.request(this.learningAt(slot) ? "cancel" : "learn", { slot }) },
|
||||
learningAt(slot) { return !!this.learning && this.learningSlot === slot },
|
||||
disabled() { return !this.offroad || !!this.busy },
|
||||
configured(slot) { return !!slot?.enabled && !!slot?.key },
|
||||
optionByKey(key) { return this.controllerOptions.find((o) => o.key === key) || null },
|
||||
isSpeedSlot(slot) { return this.optionByKey(slot?.key)?.value_type === "speed" },
|
||||
onActionSelect(i, e) {
|
||||
if (this.disabled()) return
|
||||
const key = String(e.target.value || "")
|
||||
const option = this.optionByKey(key)
|
||||
const value = option?.value_type === "speed"
|
||||
? Number(this.controllerSlots[i]?.value ?? option.default_value ?? 30)
|
||||
: null
|
||||
this.request("action", { slot: i, key, value })
|
||||
},
|
||||
onSpeedChange(i, e) {
|
||||
if (this.disabled()) return
|
||||
const key = String(this.controllerSlots[i]?.key || "")
|
||||
const value = Number(e.target.value)
|
||||
if (!Number.isFinite(value)) return
|
||||
this.request("action", { slot: i, key, value })
|
||||
},
|
||||
listenLabel(slot) {
|
||||
if (!this.learningAt(slot)) return "Learn Button"
|
||||
const seconds = Math.max(0, Math.ceil(this.remainingSeconds))
|
||||
return seconds > 0 ? `Listening (${seconds}s)` : "Listening..."
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<p v-if="!offroad" style="color: var(--text-muted);">Mappings can only be changed while offroad. Mapped buttons continue working onroad.</p>
|
||||
<p v-if="error" style="color: var(--error);">{{ error }}</p>
|
||||
<p v-if="!loading && !available && !mappings.length" style="color: var(--text-muted);">The wheel control service is starting.</p>
|
||||
<div style="display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn" :disabled="disabled() || !mappings.length" @click="request(testing ? 'test-stop' : 'test')">{{ testing ? 'Stop Testing' : 'Test Buttons' }}</button>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="disabled() || !mappings.length" @click="request('clear')">Clear All</button>
|
||||
</div>
|
||||
<div v-if="testing && lastTested" style="margin-bottom:12px;">
|
||||
<span class="gx-chip" :style="lastTested.mapped ? 'background:var(--success);' : 'background:var(--error);'">{{ lastTested.mapped ? 'Successful' : 'Not mapped' }}</span>
|
||||
<p style="color:var(--text-muted); margin-top:6px;">{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.</p>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:12px 0 8px;">Connected input devices</h4>
|
||||
<p style="color:var(--text-muted); margin:0 0 8px;">Favorite buttons are the default, with controller-only actions below. Only the selected gamepad controls Joystick Mode.</p>
|
||||
<div v-if="devices.length" style="display:grid; gap:8px;">
|
||||
<div v-for="d in devices" :key="d.device_id" class="gx-row" style="flex-wrap:wrap;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ d.name }}</span>
|
||||
<span class="gx-row__desc">{{ d.joystick_capable ? 'Buttons and joystick axes' : 'Buttons only' }}</span>
|
||||
</div>
|
||||
<button v-if="d.joystick_capable" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled()"
|
||||
@click="request('joystick', { device_id: d.device_id, enabled: !(d.device_id === joystickDevice) })">
|
||||
{{ d.device_id === joystickDevice ? 'Enabled for Joystick Mode' : 'Enable for Joystick Mode' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else style="color:var(--text-muted); margin:0;">Connect or pair a controller, macropad, or keyboard.</p>
|
||||
|
||||
<h4 style="margin:12px 0 8px;">On-screen Favorites</h4>
|
||||
<div style="display:grid; gap:8px;">
|
||||
<div v-for="(slot, i) in slots" :key="'fav'+i" class="gx-row" style="flex-wrap:wrap;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">Favorite #{{ i + 1 }}</span>
|
||||
<span class="gx-row__desc">{{ configured(slot) ? (slot.label || slot.key) : 'Not configured' }}</span>
|
||||
</div>
|
||||
<button v-if="configured(slot)" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled() || testing" @click="learn(i)">{{ listenLabel(i) }}</button>
|
||||
<span v-if="mappingsOf(i).length" class="gx-row__desc">{{ mappingsOf(i).map(m => m.event_name).join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!configured(slots[0]) && !configured(slots[1]) && !configured(slots[2])" style="color:var(--text-muted); margin:0;">Choose and enable these slots in Toggles to map buttons to them.</p>
|
||||
|
||||
<h4 style="margin:16px 0 8px;">Controller-only Actions</h4>
|
||||
<p style="color:var(--text-muted); margin:0 0 8px;">Ten additional actions for physical buttons. These never appear as on-screen Favorites.</p>
|
||||
<div style="display:grid; gap:8px;">
|
||||
<div v-for="(slot, i) in controllerSlots" :key="'act'+i" class="gx-card" style="padding:var(--sp-3); display:grid; gap:8px; margin:0;">
|
||||
<div class="gx-row" style="border:none; padding:0; flex-wrap:wrap;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">Controller Action #{{ i + 1 }}</span>
|
||||
<span class="gx-row__desc">{{ slot.enabled ? (slot.label || 'Configured') : 'Not configured' }}</span>
|
||||
</div>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!slot.enabled || disabled() || testing" @click="learn(actionSlotIndex(i))">{{ listenLabel(actionSlotIndex(i)) }}</button>
|
||||
</div>
|
||||
<select class="gx-field gx-field--full" :value="String(slot.key || '')" :disabled="disabled()" @change="onActionSelect(i, $event)">
|
||||
<option value="">Not configured</option>
|
||||
<option v-for="opt in controllerOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
|
||||
</select>
|
||||
<div v-if="isSpeedSlot(slot)" class="gx-row" style="border:none; padding:0;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">Set speed ({{ speedUnit }})</span>
|
||||
</div>
|
||||
<input class="gx-field" type="number" inputmode="decimal" style="min-width:90px;"
|
||||
:min="speedMinimum" :max="speedMaximum" step="1"
|
||||
:value="Number(slot.value ?? 30)" :disabled="disabled()" @change="onSpeedChange(i, $event)" />
|
||||
</div>
|
||||
<div v-if="learningAt(actionSlotIndex(i))" style="color:var(--text-muted); font-size:var(--fs-sm);">Press one button on your controller, macropad, or keyboard.</div>
|
||||
<div v-if="mappingsOf(actionSlotIndex(i)).length">
|
||||
<span v-for="m in mappingsOf(actionSlotIndex(i))" :key="m.id || m.event_code" class="gx-chip gx-chip--dev" style="margin-right:4px;">{{ m.event_name || ('Button ' + m.event_code) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { computed, reactive } from "vue"
|
||||
import { showSnackbar } from "./api.js"
|
||||
import { navigate, store } from "./store.js"
|
||||
|
||||
export function useTabRouting(basePath, tabs) {
|
||||
const tab = computed(() => {
|
||||
const prefix = basePath + "/"
|
||||
const slug = store.route.startsWith(prefix)
|
||||
? store.route.slice(prefix.length).split("/")[0]
|
||||
: ""
|
||||
for (const [key, s] of Object.entries(tabs)) {
|
||||
if (s === slug) return key
|
||||
}
|
||||
return Object.keys(tabs)[0]
|
||||
})
|
||||
|
||||
function selectTab(key) {
|
||||
const slug = tabs[key]
|
||||
if (slug === undefined) return
|
||||
const href = slug ? `${basePath}/${slug}` : basePath
|
||||
if (href !== store.route) navigate(href)
|
||||
}
|
||||
|
||||
return { tab, selectTab }
|
||||
}
|
||||
|
||||
export function usePolling(fn, { interval = 3000, enabled = () => true } = {}) {
|
||||
const state = reactive({ running: false, lastError: "", lastErrorAt: 0 })
|
||||
let timer = null
|
||||
let destroyed = false
|
||||
|
||||
const stop = () => { if (timer) { clearTimeout(timer); timer = null } }
|
||||
const tick = async () => {
|
||||
if (destroyed || !enabled() || document.visibilityState !== "visible") {
|
||||
timer = setTimeout(tick, interval)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fn()
|
||||
state.lastError = ""
|
||||
} catch (e) {
|
||||
state.lastError = e?.message || String(e)
|
||||
state.lastErrorAt = Date.now()
|
||||
}
|
||||
if (!destroyed) timer = setTimeout(tick, interval)
|
||||
}
|
||||
const start = () => { stop(); timer = setTimeout(tick, 0) }
|
||||
const destroy = () => { destroyed = true; stop() }
|
||||
return { state, start, stop, destroy }
|
||||
}
|
||||
|
||||
export function useLogStream({ endpoint, snapshotFn, interval = 2000 } = {}) {
|
||||
const state = reactive({ log: "", latest: "", paused: false, transport: "idle" })
|
||||
let es = null
|
||||
let timer = null
|
||||
let destroyed = false
|
||||
|
||||
const apply = (data) => {
|
||||
state.latest = data || ""
|
||||
if (!state.paused) state.log = state.latest
|
||||
}
|
||||
const snapshotFetch = async () => {
|
||||
if (!snapshotFn) return
|
||||
try { apply((await snapshotFn())?.data || "") } catch (e) { }
|
||||
}
|
||||
const stopStream = () => { if (es) { es.close(); es = null } }
|
||||
const stopPolling = () => { if (timer) { clearInterval(timer); timer = null } }
|
||||
const startPolling = () => {
|
||||
stopStream()
|
||||
state.transport = "polling"
|
||||
snapshotFetch()
|
||||
timer = setInterval(() => { if (!destroyed) snapshotFetch() }, interval)
|
||||
}
|
||||
const startStream = () => {
|
||||
stopPolling()
|
||||
if (!endpoint) return startPolling()
|
||||
state.transport = "streaming"
|
||||
es = new EventSource(endpoint)
|
||||
es.onmessage = (e) => apply(e.data)
|
||||
es.onerror = () => { if (snapshotFn) startPolling() }
|
||||
}
|
||||
const start = () => (snapshotFn ? startPolling() : startStream())
|
||||
const destroy = () => { destroyed = true; stopStream(); stopPolling() }
|
||||
const togglePause = () => {
|
||||
state.paused = !state.paused
|
||||
if (!state.paused) state.log = state.latest
|
||||
}
|
||||
const notify = (message, level) => showSnackbar(message, level)
|
||||
return { state, start, destroy, togglePause, notify }
|
||||
}
|
||||
|
||||
export function formatAgeSeconds(value) {
|
||||
const sec = Number(value)
|
||||
if (!Number.isFinite(sec) || sec < 0) return "unknown"
|
||||
if (sec < 1) return "just now"
|
||||
if (sec < 60) return `${Math.round(sec)}s ago`
|
||||
const min = sec / 60
|
||||
if (min < 60) return `${Math.round(min)}m ago`
|
||||
return `${Math.round(min / 60)}h ago`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode"
|
||||
|
||||
const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"])
|
||||
const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"])
|
||||
const VEHICLE_SETTING_MAKES = {
|
||||
RivianAngleControl: ["Rivian"],
|
||||
TeslaCoopSteering: ["Tesla"],
|
||||
NAPRadarEnabled: ["Tesla"],
|
||||
NAPRadarBehindNosecone: ["Tesla"],
|
||||
NAPRadarOffset: ["Tesla"],
|
||||
NAPPedalEnabled: ["Tesla"],
|
||||
NAPPedalCanBus: ["Tesla"],
|
||||
NAPAdaptiveAccel: ["Tesla"],
|
||||
NAPPedalCalibDone: ["Tesla"],
|
||||
NAPPedalCalibFactor: ["Tesla"],
|
||||
NAPPedalCalibZero: ["Tesla"],
|
||||
GMPedalLongitudinal: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
|
||||
GMDashSpoofOffsets: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
|
||||
IgnoreIgnitionLine: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
|
||||
LongPitch: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
|
||||
RemoteStartBootsComma: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
|
||||
HKGRemoteStartBootsComma: ["Genesis", "Hyundai", "Kia"],
|
||||
VoltSNG: ["Chevrolet", "Holden"],
|
||||
GMAutoHold: ["Chevrolet", "Holden"],
|
||||
VoltOnePedalMode: ["Chevrolet", "Holden"],
|
||||
RemapCancelToDistance: ["Chevrolet", "Holden"],
|
||||
JeepBrakeHold: ["Jeep"],
|
||||
SubaruSNG: ["Subaru"],
|
||||
SubaruSNGManualParkingBrake: ["Subaru"],
|
||||
SubaruStopStartOff: ["Subaru"],
|
||||
SubaruAvhOnAtStartup: ["Subaru"],
|
||||
ClusterOffset: ["Lexus", "Toyota"],
|
||||
SNGHack: ["Lexus", "Toyota"],
|
||||
ToyotaAutoHold: ["Lexus", "Toyota"],
|
||||
}
|
||||
|
||||
export function normalizeVehicleMake(value) {
|
||||
return String(value ?? "").trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isVehicleSettingVisible(section, param, values) {
|
||||
const allowedMakes = param.vehicle_makes || (section.name === "Vehicle" ? VEHICLE_SETTING_MAKES[param.key] : null)
|
||||
if (!allowedMakes) return true
|
||||
const selectedMake = normalizeVehicleMake(values.CarMake)
|
||||
return allowedMakes.some((make) => normalizeVehicleMake(make) === selectedMake)
|
||||
}
|
||||
|
||||
function toSelectValue(value) {
|
||||
return value === null || value === undefined ? "" : String(value)
|
||||
}
|
||||
|
||||
export function matchesSettingValueCondition(param, values) {
|
||||
if (!param.visible_when_key) return true
|
||||
const allowedValues = Array.isArray(param.visible_when_values) ? param.visible_when_values : []
|
||||
const currentValue = toSelectValue(values[param.visible_when_key])
|
||||
return allowedValues.some((value) => toSelectValue(value) === currentValue)
|
||||
}
|
||||
|
||||
export function isSettingVisible(section, param, values) {
|
||||
if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
|
||||
if (param.requires_capability && !values[param.requires_capability]) return false
|
||||
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
|
||||
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
|
||||
if (values[GALAXY_DEVELOPER_MODE_KEY]) return true
|
||||
return section.name === "Favorites" || param.settings_tier === "simple"
|
||||
}
|
||||
|
||||
export function isAdvancedHiddenByDeveloperMode(section, param, values) {
|
||||
if (param.settings_tier !== "advanced") return false
|
||||
if (HIDDEN_SETTING_KEYS.has(param.key)) return false
|
||||
if (!isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
|
||||
if (param.requires_capability && !values[param.requires_capability]) return false
|
||||
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
|
||||
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function countAdvancedHiddenByDeveloperMode(layout, values) {
|
||||
if (values[GALAXY_DEVELOPER_MODE_KEY]) return 0
|
||||
let count = 0
|
||||
for (const section of layout) {
|
||||
if (section.name === "Favorites") continue
|
||||
for (const param of section.params || []) {
|
||||
if (isAdvancedHiddenByDeveloperMode(section, param, values)) count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export function numericBounds(param, values) {
|
||||
const defaultBounds = {
|
||||
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
|
||||
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
|
||||
step: param.step !== undefined ? param.step : (param.data_type === "float" ? 0.01 : 1),
|
||||
}
|
||||
const toFinite = (value) => {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
if (param.key === "ScreenBrightness" || param.key === "ScreenBrightnessOnroad") {
|
||||
return { min: 1, max: 101, step: 1 }
|
||||
}
|
||||
if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) {
|
||||
return { min: 25, max: 200, step: 1 }
|
||||
}
|
||||
if (param.key === "SteerKP") {
|
||||
const base = toFinite(values?.SteerKPStock) || toFinite(values?.SteerKP) || 0.6
|
||||
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
|
||||
}
|
||||
if (param.key === "SteerLatAccel") {
|
||||
const base = toFinite(values?.SteerLatAccelStock) || toFinite(values?.SteerLatAccel) || 2.0
|
||||
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.25).toFixed(2), step: 0.01 }
|
||||
}
|
||||
if (param.key === "SteerRatio") {
|
||||
const base = toFinite(values?.SteerRatioStock) || toFinite(values?.SteerRatio) || 15.0
|
||||
return { min: +(base * 0.25).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
|
||||
}
|
||||
return defaultBounds
|
||||
}
|
||||
|
||||
export function stepPrecision(step, explicitPrecision) {
|
||||
if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") {
|
||||
const parsed = Number.parseInt(explicitPrecision, 10)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||
}
|
||||
const stepStr = String(step ?? "")
|
||||
if (!stepStr.includes(".")) return 0
|
||||
return stepStr.split(".")[1].length
|
||||
}
|
||||
|
||||
export function numericEpsilon(precision) {
|
||||
return Math.pow(10, -(precision + 2))
|
||||
}
|
||||
|
||||
export function clampNumeric(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function snapNumericToBoundsAndStep(rawValue, bounds, precision) {
|
||||
const min = Number(bounds.min)
|
||||
const max = Number(bounds.max)
|
||||
const step = Number(bounds.step)
|
||||
const value = Number(rawValue)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null
|
||||
const clamped = clampNumeric(value, min, max)
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
return clampNumeric(Number(clamped.toFixed(precision)), min, max)
|
||||
}
|
||||
const snapped = min + Math.round((clamped - min) / step) * step
|
||||
return clampNumeric(Number(snapped.toFixed(precision)), min, max)
|
||||
}
|
||||
|
||||
export function coerceValueByType(rawValue, dataType) {
|
||||
if (dataType === "int") {
|
||||
const n = Number.parseInt(rawValue, 10)
|
||||
return Number.isFinite(n) ? n : rawValue
|
||||
}
|
||||
if (dataType === "float") {
|
||||
const n = Number.parseFloat(rawValue)
|
||||
return Number.isFinite(n) ? n : rawValue
|
||||
}
|
||||
return rawValue
|
||||
}
|
||||
|
||||
export function formatSliderValue(val, stepStr, precisionInt, key) {
|
||||
if (val === null || val === undefined) return "--"
|
||||
const v = parseFloat(val)
|
||||
if (Number.isNaN(v)) return val
|
||||
if (key === "SwitchbackModeCooldown") {
|
||||
if (v === 0) return "Off"
|
||||
return v === 1 ? "1 min" : `${v} min`
|
||||
}
|
||||
if (key === "DeviceShutdown") {
|
||||
return v === 1 ? "1 hour" : `${v} hours`
|
||||
}
|
||||
const volumeKeys = [
|
||||
"BelowSteerSpeedVolume", "DisengageVolume", "EngageVolume", "PromptVolume",
|
||||
"PromptDistractedVolume", "RefuseVolume", "WarningImmediateVolume", "WarningSoftVolume",
|
||||
]
|
||||
if (key && volumeKeys.includes(key)) {
|
||||
if (v === 0) return "Muted"
|
||||
if (v === 101) return "Auto"
|
||||
return `${v}%`
|
||||
}
|
||||
if (precisionInt !== undefined && precisionInt !== null) {
|
||||
return Number(v.toFixed(precisionInt)).toString()
|
||||
}
|
||||
if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
|
||||
const dec = stepStr.split(".")[1].length
|
||||
return Number(v.toFixed(dec)).toString()
|
||||
}
|
||||
|
||||
export function formatReadoutValue(p, value) {
|
||||
const raw = value
|
||||
const parsed = parseFloat(raw)
|
||||
if (raw === undefined || raw === null || Number.isNaN(parsed)) return "--"
|
||||
const precision = p.precision !== undefined && p.precision !== null ? Number(p.precision) : 2
|
||||
const formatted = Number(parsed.toFixed(Math.max(0, precision))).toString()
|
||||
return p.unit ? `${formatted}${p.unit}` : formatted
|
||||
}
|
||||
|
||||
export function normalizeHexColor(rawValue) {
|
||||
const value = String(rawValue ?? "").trim()
|
||||
if (!value || value.toLowerCase() === "stock") return ""
|
||||
const stripped = value.startsWith("#") ? value.slice(1) : value
|
||||
if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(stripped)) return ""
|
||||
return `#${stripped.slice(0, 6).toLowerCase()}`
|
||||
}
|
||||
|
||||
export function getColorDefault(param) {
|
||||
const candidate = normalizeHexColor(param?.default_color)
|
||||
if (candidate) return candidate
|
||||
return { LaneLinesColor: "#00ff00", PathEdgesColor: "#00ff00", PathColor: "#30ff9c" }[param?.key] || "#ffffff"
|
||||
}
|
||||
|
||||
export function slugifySectionName(name) {
|
||||
return String(name || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
export function isGroupParam(param) {
|
||||
return !!param && param.ui_type === "group"
|
||||
}
|
||||
|
||||
export function applyParamChange(values, patch) {
|
||||
const next = { ...(values || {}) }
|
||||
if (!patch || typeof patch !== "object") return next
|
||||
if ("key" in patch && "value" in patch) {
|
||||
next[patch.key] = patch.value
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (k !== "key" && k !== "value") next[k] = v
|
||||
}
|
||||
} else {
|
||||
Object.assign(next, patch)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function isParamEnabledForChildren(paramOrKey, values) {
|
||||
const param = typeof paramOrKey === "string" ? { key: paramOrKey } : paramOrKey
|
||||
if (isGroupParam(param)) return true
|
||||
return !!(param && param.key && values[param.key])
|
||||
}
|
||||
|
||||
export function hasChildParams(paramsList, key) {
|
||||
return (paramsList || []).some((param) => (param.parent_key || null) === key)
|
||||
}
|
||||
|
||||
export function buildRenderTree(paramsList, values, expanded, isVisible) {
|
||||
const out = []
|
||||
const list = paramsList || []
|
||||
const visible = isVisible || (() => true)
|
||||
|
||||
function walk(parentKey, depth) {
|
||||
for (const param of list) {
|
||||
if ((param.parent_key || null) !== parentKey) continue
|
||||
if (!visible(param)) continue
|
||||
out.push({ param, depth })
|
||||
if (hasChildParams(list, param.key) && isParamEnabledForChildren(param, values) && expanded[param.key]) {
|
||||
walk(param.key, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(null, 0)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { reactive } from "vue"
|
||||
|
||||
const THEME_KEY = "galaxy-theme"
|
||||
|
||||
function initialTheme() {
|
||||
return localStorage.getItem(THEME_KEY) || "dark"
|
||||
}
|
||||
|
||||
export const store = reactive({
|
||||
route: "/",
|
||||
params: {},
|
||||
drawerOpen: false,
|
||||
search: "",
|
||||
snackbar: null,
|
||||
online: false,
|
||||
deviceStatus: "Parked",
|
||||
history: ["/"],
|
||||
theme: initialTheme(),
|
||||
})
|
||||
|
||||
export function setTheme(theme) {
|
||||
const next = theme === "light" ? "light" : "dark"
|
||||
store.theme = next
|
||||
document.documentElement.setAttribute("data-theme", next)
|
||||
try { localStorage.setItem(THEME_KEY, next) } catch (e) {}
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
setTheme(store.theme === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
export function parseHash(hash) {
|
||||
const raw = hash.replace(/^#/, "") || "/"
|
||||
const [pathname, queryString] = raw.split("?")
|
||||
const params = {}
|
||||
if (queryString) {
|
||||
for (const pair of queryString.split("&")) {
|
||||
const [k, v] = pair.split("=")
|
||||
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v || "")
|
||||
}
|
||||
}
|
||||
return { path: pathname, params }
|
||||
}
|
||||
|
||||
// Rebuild the canonical hash string for a route, preserving its query params.
|
||||
export function toHash(route) {
|
||||
const { path, params } = parseHash(route)
|
||||
const qs = Object.keys(params).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join("&")
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
function currentPath() {
|
||||
return parseHash(window.location.hash).path
|
||||
}
|
||||
|
||||
function pushIfNew(route) {
|
||||
const last = store.history[store.history.length - 1]
|
||||
if (last !== route) store.history.push(route)
|
||||
}
|
||||
|
||||
function applyRoute(route, { scrollToTop = false } = {}) {
|
||||
const { path, params } = parseHash(route)
|
||||
const pathChanged = path !== store.route
|
||||
store.route = path
|
||||
store.params = params
|
||||
store.drawerOpen = false
|
||||
// Only jump to the top for real view changes. In-place hash updates (a Manage
|
||||
// panel opening under the same section, an embed switching src) must not yank
|
||||
// the reader back to the top of the page.
|
||||
if (scrollToTop || pathChanged) window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
export function navigate(target) {
|
||||
const { path } = parseHash(target)
|
||||
if (path === currentPath()) {
|
||||
applyRoute(target)
|
||||
window.location.hash = toHash(target)
|
||||
return
|
||||
}
|
||||
pushIfNew(path)
|
||||
applyRoute(target)
|
||||
window.location.hash = toHash(target)
|
||||
}
|
||||
|
||||
export function goHome() {
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
export function goBack() {
|
||||
const current = currentPath()
|
||||
if (store.history[store.history.length - 1] === current && store.history.length > 1) {
|
||||
store.history.pop()
|
||||
}
|
||||
const prev = store.history[store.history.length - 1] || "/"
|
||||
applyRoute(prev, { scrollToTop: true })
|
||||
window.location.hash = prev
|
||||
}
|
||||
|
||||
const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed"])
|
||||
|
||||
export function toolHref(link) {
|
||||
const path = link.split("?")[0]
|
||||
if (NATIVE_ROOTS.has(path) || path.startsWith("/settings/") || path.startsWith("/embed")) return link
|
||||
return "/embed?src=" + encodeURIComponent(path)
|
||||
}
|
||||
|
||||
export function initRouter() {
|
||||
const apply = () => {
|
||||
const route = (window.location.hash || "").replace(/^#/, "") || "/"
|
||||
const { path, params } = parseHash(route)
|
||||
const pathChanged = path !== store.route
|
||||
store.route = path
|
||||
store.params = params
|
||||
store.drawerOpen = false
|
||||
pushIfNew(route)
|
||||
if (pathChanged) window.scrollTo(0, 0)
|
||||
}
|
||||
window.addEventListener("hashchange", apply)
|
||||
store.history = ["/"]
|
||||
apply()
|
||||
setTheme(store.theme)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
|
||||
|
||||
export const Home = {
|
||||
name: "Home",
|
||||
components: { GalaxyEmbed },
|
||||
template: `
|
||||
<div class="gx-view">
|
||||
<GalaxyEmbed src="/classic" title="Home" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { useLogStream } from "../composables.js"
|
||||
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
||||
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
|
||||
|
||||
function parseLogDate(filename) {
|
||||
const m = filename.match(/(\d{4})-(\d{2})-(\d{2})[T_]?(\d{2})-?(\d{2})-?(\d{2})?/)
|
||||
if (!m) return new Date()
|
||||
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0))
|
||||
return isNaN(date.getTime()) ? new Date() : date
|
||||
}
|
||||
|
||||
export const Logs = {
|
||||
name: "Logs",
|
||||
components: { GalaxyEmbed },
|
||||
data() {
|
||||
return {
|
||||
tab: "errors",
|
||||
errorFiles: [],
|
||||
errorsLoading: true,
|
||||
selectedLog: "",
|
||||
logContent: "",
|
||||
logLoading: false,
|
||||
tmuxFiles: [],
|
||||
tmuxLoading: true,
|
||||
troubleshootData: null,
|
||||
troubleshootRunning: false,
|
||||
searchFilter: "",
|
||||
tmuxAutoScroll: true,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
|
||||
},
|
||||
watch: {
|
||||
"stream.state.log"() {
|
||||
this.$nextTick(() => {
|
||||
if (this.stream.state.paused || !this.tmuxAutoScroll) return
|
||||
const el = this.$refs.tmuxtail
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadErrorLogs()
|
||||
this.loadTmuxLogs()
|
||||
this.stream.start()
|
||||
this.loadTroubleshoot()
|
||||
},
|
||||
beforeUnmount() { this.stream.destroy() },
|
||||
computed: {
|
||||
filteredErrorFiles() {
|
||||
if (!this.searchFilter) return this.errorFiles
|
||||
const q = this.searchFilter.toLowerCase()
|
||||
return this.errorFiles.filter((f) => f.filename.toLowerCase().includes(q))
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadErrorLogs() {
|
||||
try {
|
||||
const files = await api.getErrorLogs()
|
||||
this.errorFiles = files.map((f) => {
|
||||
const date = parseLogDate(f)
|
||||
return { filename: f, date: date.toLocaleString() }
|
||||
})
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to load error logs.", "error")
|
||||
} finally {
|
||||
this.errorsLoading = false
|
||||
}
|
||||
},
|
||||
async viewLog(file) {
|
||||
this.selectedLog = file.filename
|
||||
this.logLoading = true
|
||||
try {
|
||||
this.logContent = await api.getErrorLog(file.filename)
|
||||
} catch (e) {
|
||||
this.logContent = "Could not load this log."
|
||||
} finally {
|
||||
this.logLoading = false
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.logview
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
},
|
||||
async deleteLog(file) {
|
||||
if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
|
||||
await api.deleteErrorLog(file.filename)
|
||||
this.errorFiles = this.errorFiles.filter((f) => f.filename !== file.filename)
|
||||
if (this.selectedLog === file.filename) this.selectedLog = ""
|
||||
showSnackbar("Log deleted!")
|
||||
},
|
||||
async deleteAllLogs() {
|
||||
if (!(await GalaxyConfirm({ title: "Delete all error logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
|
||||
try {
|
||||
const ok = await api.deleteAllErrorLogs()
|
||||
if (!ok) throw new Error("Delete failed")
|
||||
this.errorFiles = []
|
||||
this.selectedLog = ""
|
||||
showSnackbar("All error logs deleted!")
|
||||
} catch (e) {
|
||||
showSnackbar("Delete all failed.", "error")
|
||||
}
|
||||
},
|
||||
copyLog() {
|
||||
const text = this.logContent
|
||||
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text)
|
||||
else { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove() }
|
||||
showSnackbar("Copied to clipboard!")
|
||||
},
|
||||
async loadTmuxLogs() {
|
||||
try {
|
||||
const files = await api.getTmuxLogs()
|
||||
this.tmuxFiles = files.map((f) => {
|
||||
const date = new Date(f.timestamp * 1000)
|
||||
return { filename: f.filename, date: date.toLocaleString() }
|
||||
})
|
||||
} catch (e) {
|
||||
this.tmuxFiles = []
|
||||
} finally {
|
||||
this.tmuxLoading = false
|
||||
}
|
||||
},
|
||||
async captureTmux() {
|
||||
const ok = await api.tmuxCapture()
|
||||
showSnackbar(ok ? "Current session captured!" : "Capture failed.", ok ? "info" : "error")
|
||||
this.tmuxLoading = true
|
||||
await this.loadTmuxLogs()
|
||||
},
|
||||
async deleteTmux(file) {
|
||||
if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
|
||||
const ok = await api.deleteTmuxLog(file.filename)
|
||||
showSnackbar(ok ? "Deleted!" : "Delete failed.", ok ? "info" : "error")
|
||||
this.tmuxFiles = this.tmuxFiles.filter((f) => f.filename !== file.filename)
|
||||
},
|
||||
async deleteAllTmux() {
|
||||
if (!(await GalaxyConfirm({ title: "Delete all logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
|
||||
const ok = await api.deleteAllTmuxLogs()
|
||||
showSnackbar(ok ? "All logs deleted!" : "Delete failed.", ok ? "info" : "error")
|
||||
this.tmuxFiles = []
|
||||
},
|
||||
async loadTroubleshoot() {
|
||||
try {
|
||||
this.troubleshootData = await api.getTroubleshoot()
|
||||
} catch (e) { this.troubleshootData = null }
|
||||
},
|
||||
async runTroubleshoot() {
|
||||
this.troubleshootRunning = true
|
||||
try {
|
||||
this.troubleshootData = await api.runTroubleshoot()
|
||||
} catch (e) {
|
||||
showSnackbar("Troubleshoot failed.", "error")
|
||||
} finally {
|
||||
this.troubleshootRunning = false
|
||||
}
|
||||
},
|
||||
async resetTroubleshoot() {
|
||||
const ok = await api.resetTroubleshoot()
|
||||
if (ok) this.troubleshootData = null
|
||||
showSnackbar(ok ? "Troubleshoot reset!" : "Reset failed.", ok ? "info" : "error")
|
||||
},
|
||||
onTmuxScroll(e) {
|
||||
const el = e.target
|
||||
this.tmuxAutoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="gx-view">
|
||||
<h2 style="margin-top:0;">Logs & Diagnostics</h2>
|
||||
|
||||
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-chip" :style="tab==='errors'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='errors'">Error Logs</button>
|
||||
<button type="button" class="gx-chip" :style="tab==='tmux'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='tmux'">Tmux Live Log</button>
|
||||
<button type="button" class="gx-chip" :style="tab==='troubleshoot'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='troubleshoot'">Troubleshoot</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'errors'">
|
||||
<div style="display:flex; gap:8px; margin-bottom:8px; align-items:center;">
|
||||
<input class="gx-field" style="flex:1;" type="search" v-model="searchFilter" placeholder="Search logs..." />
|
||||
<button v-if="errorFiles.length" type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllLogs()">Delete All</button>
|
||||
</div>
|
||||
<section class="gx-card">
|
||||
<div v-if="errorsLoading" class="gx-loading">Loading...</div>
|
||||
<div v-else-if="!filteredErrorFiles.length" class="gx-empty">No error logs!</div>
|
||||
<div v-for="f in filteredErrorFiles" :key="f.filename" class="gx-row" style="cursor:pointer;" @click="viewLog(f)">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ f.date }}</span>
|
||||
<span class="gx-row__desc">{{ f.filename }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<a class="gx-btn gx-btn--tonal" :href="'/api/error_logs/' + encodeURIComponent(f.filename)" download><i class="bi bi-download"></i></a>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click.stop="deleteLog(f)"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="selectedLog" class="gx-card" style="margin-top:12px;">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-file-text"></i>
|
||||
<span class="gx-section__title">{{ selectedLog }}</span>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="copyLog"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<pre ref="logview" style="max-height:60vh; overflow:auto; padding:var(--sp-3); font-size:12px; line-height:1.5; white-space:pre-wrap;">{{ logLoading ? 'Loading...' : logContent }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'tmux'">
|
||||
<section class="gx-card">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-terminal"></i>
|
||||
<span class="gx-section__title">Tmux Live Log</span>
|
||||
<span class="gx-chip" :style="stream.state.transport === 'streaming' ? 'background:var(--success);' : ''">{{ stream.state.transport }}</span>
|
||||
</div>
|
||||
<pre ref="tmuxtail" class="gx-terminal" @scroll.passive="onTmuxScroll">{{ stream.state.log || '(waiting for log output…)' }}</pre>
|
||||
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="stream.togglePause()">{{ stream.state.paused ? '▶️ Resume' : '⏸️ Pause' }}</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="captureTmux">Capture Log</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllTmux">Delete All</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="gx-card" style="margin-top:12px;">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-collection"></i>
|
||||
<span class="gx-section__title">Saved Session Logs</span>
|
||||
</div>
|
||||
<div v-if="tmuxLoading" class="gx-loading">Loading...</div>
|
||||
<div v-else-if="!tmuxFiles.length" class="gx-empty">No tmux logs found.</div>
|
||||
<div v-for="f in tmuxFiles" :key="f.filename" class="gx-row">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ f.filename }}</span>
|
||||
<span class="gx-row__desc">{{ f.date }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<a class="gx-btn gx-btn--tonal" :href="'/api/tmux_log/download/' + encodeURIComponent(f.filename)" download><i class="bi bi-download"></i></a>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteTmux(f)"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<GalaxyEmbed src="/troubleshoot" title="Troubleshoot" />
|
||||
</template>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { GalaxySection } from "../components/GalaxySection.js"
|
||||
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
|
||||
import { useTabRouting } from "../composables.js"
|
||||
|
||||
export const Navigation = {
|
||||
name: "Navigation",
|
||||
components: { GalaxySection, GalaxyEmbed },
|
||||
data() {
|
||||
return { destination: "", favorites: [], navLoading: true }
|
||||
},
|
||||
setup() {
|
||||
return useTabRouting("/navigation", {
|
||||
nav: "", maps: "maps", keys: "keys", speeds: "speeds",
|
||||
})
|
||||
},
|
||||
computed: {
|
||||
embedSrc() {
|
||||
const sources = {
|
||||
maps: "/manage_maps",
|
||||
keys: "/manage_navigation_keys",
|
||||
speeds: "/download_speed_limits",
|
||||
}
|
||||
return sources[this.tab] || ""
|
||||
},
|
||||
embedTitle() {
|
||||
const titles = {
|
||||
maps: "Maps",
|
||||
keys: "App Keys",
|
||||
speeds: "Speed Limits",
|
||||
}
|
||||
return titles[this.tab] || "Navigation"
|
||||
},
|
||||
},
|
||||
mounted() { this.loadNavigation() },
|
||||
methods: {
|
||||
async loadNavigation() {
|
||||
this.navLoading = true
|
||||
try {
|
||||
const data = await api.getNavigation()
|
||||
this.destination = data?.destination || data?.name || ""
|
||||
this.favorites = Array.isArray(data?.favorites) ? data.favorites : []
|
||||
} catch (e) {
|
||||
this.favorites = []
|
||||
} finally {
|
||||
this.navLoading = false
|
||||
}
|
||||
},
|
||||
async setDestination() {
|
||||
if (!this.destination) return
|
||||
try {
|
||||
const payload = await api.setNavigation({ destination: this.destination })
|
||||
showSnackbar(payload?.message || "Destination set.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Failed to set destination.", "error")
|
||||
}
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="gx-view">
|
||||
<h2 style="margin-top:0;">Navigation & Maps</h2>
|
||||
|
||||
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-chip" :style="tab==='nav'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('nav')">Destination</button>
|
||||
<button type="button" class="gx-chip" :style="tab==='maps'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('maps')">Maps</button>
|
||||
<button type="button" class="gx-chip" :style="tab==='keys'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('keys')">App Keys</button>
|
||||
<button type="button" class="gx-chip" :style="tab==='speeds'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('speeds')">Speed Limits</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'nav'">
|
||||
<GalaxySection title="Navigation Destination" icon="bi-geo-alt-fill">
|
||||
<div style="padding: var(--sp-3); display:grid; gap:8px;">
|
||||
<input class="gx-field" v-model="destination" placeholder="Destination address or name" />
|
||||
<button type="button" class="gx-btn" @click="setDestination"><i class="bi bi-send"></i> Send to Device</button>
|
||||
<div v-if="favorites.length">
|
||||
<h4 style="margin:12px 0 8px;">Favorites</h4>
|
||||
<div v-for="fav in favorites" :key="fav.name" class="gx-row">
|
||||
<span class="gx-row__label">{{ fav.name }}</span>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="destination = fav.name; setDestination()">Use</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GalaxySection>
|
||||
</template>
|
||||
|
||||
<GalaxyEmbed v-else-if="embedSrc" :src="embedSrc" :title="embedTitle" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
||||
import { GalaxySection } from "../components/GalaxySection.js"
|
||||
|
||||
function fmtDuration(seconds) {
|
||||
seconds = Number(seconds) || 0
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "0 MB"
|
||||
const mb = bytes / 1e6
|
||||
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function normalizeRoute(r) {
|
||||
const name = String(r?.name || "")
|
||||
const isCustomName = !!r?.isCustomName
|
||||
return {
|
||||
name,
|
||||
displayName: r?.displayName || name.split("--").pop() || name,
|
||||
displayDate: r?.displayDate || "",
|
||||
approxDurationSeconds: Number(r?.approxDurationSeconds || 0),
|
||||
segmentCount: Number(r?.segmentCount || r?.numSegments || 0),
|
||||
is_preserved: !!r?.is_preserved,
|
||||
isCustomName,
|
||||
png: r?.png || "",
|
||||
}
|
||||
}
|
||||
|
||||
export const Recordings = {
|
||||
name: "Recordings",
|
||||
components: { GalaxySection },
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
error: "",
|
||||
routes: [],
|
||||
progress: 0,
|
||||
searchQuery: "",
|
||||
sortOrder: "newest",
|
||||
showPreservedOnly: false,
|
||||
playerRoute: null,
|
||||
playerLoading: false,
|
||||
playerError: "",
|
||||
segments: [],
|
||||
current: 0,
|
||||
cameras: [],
|
||||
selectedCamera: "",
|
||||
logsRoute: null,
|
||||
logsData: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
stats() {
|
||||
return {
|
||||
count: this.routes.length,
|
||||
formattedDuration: fmtDuration(this.routes.reduce((n, r) => n + r.approxDurationSeconds, 0)),
|
||||
preservedCount: this.routes.filter((r) => r.is_preserved).length,
|
||||
}
|
||||
},
|
||||
visibleRoutes() {
|
||||
let list = this.routes.slice()
|
||||
if (this.showPreservedOnly) list = list.filter((r) => r.is_preserved)
|
||||
if (this.searchQuery.trim()) {
|
||||
const q = this.searchQuery.toLowerCase()
|
||||
list = list.filter((r) => [r.displayName, r.displayDate, r.name].some((v) => String(v || "").toLowerCase().includes(q)))
|
||||
}
|
||||
const sorters = {
|
||||
newest: (a, b) => (b.name > a.name ? 1 : -1),
|
||||
oldest: (a, b) => (a.name > b.name ? 1 : -1),
|
||||
longest: (a, b) => b.approxDurationSeconds - a.approxDurationSeconds,
|
||||
shortest: (a, b) => a.approxDurationSeconds - b.approxDurationSeconds,
|
||||
}
|
||||
return list.sort(sorters[this.sortOrder] || sorters.newest)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fmtDuration,
|
||||
formatBytes,
|
||||
async loadRoutes() {
|
||||
this.loading = true
|
||||
this.error = ""
|
||||
this.routes = []
|
||||
this.progress = 0
|
||||
const seen = new Set()
|
||||
try {
|
||||
this.controller?.abort()
|
||||
this.controller = new AbortController()
|
||||
await api.getRoutesStream({
|
||||
signal: this.controller.signal,
|
||||
onProgress: (p) => { this.progress = p },
|
||||
onRoutes: (raw) => {
|
||||
for (const r of raw) {
|
||||
if (seen.has(r.name)) continue
|
||||
seen.add(r.name)
|
||||
this.routes.push(normalizeRoute(r))
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
if (e?.name !== "AbortError") this.error = "Couldn't load routes. Try refreshing."
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async deleteRoute(route) {
|
||||
if (!(await GalaxyConfirm({ title: "Delete route?", message: `Delete “${route.displayName}”?`, confirmLabel: "Delete", danger: true }))) return
|
||||
try {
|
||||
await api.deleteRoute(route.name)
|
||||
this.routes = this.routes.filter((r) => r.name !== route.name)
|
||||
showSnackbar("Route deleted!")
|
||||
} catch (e) {
|
||||
showSnackbar("Delete failed.", "error")
|
||||
}
|
||||
},
|
||||
async togglePreserved(route) {
|
||||
try {
|
||||
await api.setRoutePreserved(route.name, !route.is_preserved)
|
||||
route.is_preserved = !route.is_preserved
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to update preserved state.", "error")
|
||||
}
|
||||
},
|
||||
async renameRoute(route) {
|
||||
const newName = prompt("Rename route:", route.displayName)
|
||||
if (!newName || newName === route.displayName) return
|
||||
try {
|
||||
const payload = await api.renameRoute(route.name, newName)
|
||||
Object.assign(route, normalizeRoute({ ...route, name: payload.name || newName, isCustomName: true }))
|
||||
route.displayName = payload.name || newName
|
||||
showSnackbar("Route renamed!")
|
||||
} catch (e) {
|
||||
showSnackbar("Rename failed.", "error")
|
||||
}
|
||||
},
|
||||
async deleteAllRoutes(includePreserved) {
|
||||
const label = includePreserved ? "Delete all routes, including preserved?" : "Delete all non-preserved routes?"
|
||||
if (!(await GalaxyConfirm({ title: label, message: "This action cannot be undone.", confirmLabel: includePreserved ? "Delete Everything" : "Delete Non-Preserved", danger: true }))) return
|
||||
try {
|
||||
const payload = await api.deleteAllRoutes(includePreserved)
|
||||
this.routes = []
|
||||
showSnackbar(payload?.message || "Routes deleted!")
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to delete routes.", "error")
|
||||
}
|
||||
},
|
||||
async openPlayer(route) {
|
||||
this.playerRoute = route
|
||||
this.playerLoading = true
|
||||
this.playerError = ""
|
||||
try {
|
||||
const data = await api.getRoute(route.name)
|
||||
const segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter((u) => typeof u === "string") : []
|
||||
const cameras = ["forward", "wide", "driver"].filter((c) => data.available_cameras?.includes(c))
|
||||
if (!segments.length) throw new Error("No video segments for this route.")
|
||||
if (!cameras.length) throw new Error("No camera video for this route.")
|
||||
this.segments = segments
|
||||
this.current = 0
|
||||
this.cameras = cameras
|
||||
this.selectedCamera = cameras.includes("forward") ? "forward" : cameras[0]
|
||||
this.$nextTick(() => this.playSegment())
|
||||
} catch (e) {
|
||||
this.playerError = e?.message || "Could not load route."
|
||||
} finally {
|
||||
this.playerLoading = false
|
||||
}
|
||||
},
|
||||
cameraUrl(url, low) {
|
||||
if (this.selectedCamera === "forward") return low && !url.includes("?" ) ? `${url}?quality=low` : url
|
||||
const sep = url.includes("?") ? "&" : "?"
|
||||
return `${url}${sep}camera=${encodeURIComponent(this.selectedCamera)}${low ? "&quality=low" : ""}`
|
||||
},
|
||||
playSegment() {
|
||||
const video = this.$refs.player
|
||||
if (!video || !this.segments[this.current]) return
|
||||
video.src = this.cameraUrl(this.segments[this.current])
|
||||
video.load()
|
||||
video.play().catch(() => {})
|
||||
},
|
||||
downloadRoute() {
|
||||
if (!this.playerRoute) return
|
||||
const a = document.createElement("a")
|
||||
a.href = `/video/${this.playerRoute.name}/combined?camera=${encodeURIComponent(this.selectedCamera)}`
|
||||
a.download = `${this.playerRoute.displayName}-${this.selectedCamera}.mp4`
|
||||
a.click()
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.$refs.player) { this.$refs.player.pause(); this.$refs.player.removeAttribute("src") }
|
||||
this.playerRoute = null
|
||||
this.playerLoading = false
|
||||
this.playerError = ""
|
||||
this.segments = []
|
||||
this.cameras = []
|
||||
},
|
||||
async openLogs(route) {
|
||||
try {
|
||||
this.logsData = await api.getRouteLogs(route.name)
|
||||
this.logsRoute = route
|
||||
} catch (e) {
|
||||
showSnackbar("Could not read logs.", "error")
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() { await this.loadRoutes() },
|
||||
beforeUnmount() { this.controller?.abort() },
|
||||
template: `
|
||||
<div>
|
||||
<h2 style="margin-top:0;">Recordings</h2>
|
||||
|
||||
<section class="gx-card">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-camera-reels"></i>
|
||||
<span class="gx-section__title">Dashcam Routes</span>
|
||||
<span class="gx-section__count">{{ stats.count }} drives · {{ stats.formattedDuration }}</span>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<input class="gx-field" style="flex:1; min-width:160px;" type="search" placeholder="Search routes..." v-model="searchQuery" />
|
||||
<select class="gx-field" v-model="sortOrder">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="longest">Longest duration</option>
|
||||
<option value="shortest">Shortest duration</option>
|
||||
</select>
|
||||
<button type="button" class="gx-chip" :style="!showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=false">All</button>
|
||||
<button type="button" class="gx-chip" :style="showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=true">Preserved</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="gx-loading">Finding local routes... ({{ Math.round(progress) }}%)</div>
|
||||
<div v-if="error" class="gx-empty" style="color: var(--error);">{{ error }}</div>
|
||||
<section class="gx-card">
|
||||
<div v-if="!visibleRoutes.length && !loading" class="gx-empty">No routes found.</div>
|
||||
<article v-for="r in visibleRoutes" :key="r.name" class="gx-row" style="cursor:pointer;" @click="openPlayer(r)">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ r.displayName }} <span v-if="r.is_preserved" class="gx-chip gx-chip--dev">Preserved</span></span>
|
||||
<span class="gx-row__desc">{{ fmtDuration(r.approxDurationSeconds) }} · {{ r.segmentCount }} segments</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Preserve" @click.stop="togglePreserved(r)"><i class="bi" :class="r.is_preserved ? 'bi-heart-fill' : 'bi-heart'"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Logs" @click.stop="openLogs(r)"><i class="bi bi-file-earmark-arrow-down"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Rename" @click.stop="renameRoute(r)"><i class="bi bi-pencil"></i></button>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" title="Delete" @click.stop="deleteRoute(r)"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="gx-card" v-if="routes.length">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span class="gx-section__title">Delete local routes</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllRoutes(false)">Delete Non-Preserved</button>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllRoutes(true)">Delete All Including Preserved</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="logsRoute && logsData" class="gx-card" style="margin-top:12px;">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-file-earmark-arrow-down"></i>
|
||||
<span class="gx-section__title">{{ logsData.segments?.length || 0 }} segments · {{ formatBytes(logsData.totalBytes) }}</span>
|
||||
<a class="gx-btn gx-btn--tonal" :href="'/api/routes/' + logsRoute.name + '/logs/download'" download>Download all (.tar)</a>
|
||||
</div>
|
||||
<div v-for="seg in logsData.segments || []" :key="seg.segmentNum" class="gx-row">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">Segment {{ seg.segmentNum }}</span>
|
||||
<span class="gx-row__desc">{{ seg.filename }} · {{ formatBytes(seg.bytes) }}</span>
|
||||
</div>
|
||||
<a class="gx-btn gx-btn--tonal" :href="seg.url" download>Download</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="gx-fade">
|
||||
<div v-if="playerRoute" class="gx-scrim" @click.self="closePlayer">
|
||||
<div class="gx-sheet" style="max-width:640px; width:100%;" role="dialog" aria-label="Route video player">
|
||||
<div class="gx-section__header" style="cursor:default;">
|
||||
<i class="bi bi-camera-video"></i>
|
||||
<span class="gx-section__title">{{ playerRoute.displayName }}</span>
|
||||
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closePlayer"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<div v-if="playerError" class="gx-empty" style="color: var(--error);">{{ playerError }}</div>
|
||||
<div v-else-if="playerLoading" class="gx-loading"><i class="bi bi-hourglass-split"></i> Loading video...</div>
|
||||
<template v-else-if="segments.length">
|
||||
<video ref="player" class="gx-video" controls muted playsinline preload="metadata"></video>
|
||||
<div style="display:flex; gap:8px; padding: var(--sp-3) 0 0; flex-wrap:wrap; align-items:center;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-skip-start-fill"></i></button>
|
||||
<select class="gx-field" :value="current" @change="current = Number($event.target.value); playSegment()">
|
||||
<option v-for="(s,i) in segments" :key="i" :value="i">Segment {{ i + 1 }}</option>
|
||||
</select>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current>=segments.length-1" @click="current++; playSegment()"><i class="bi bi-skip-end-fill"></i></button>
|
||||
<button v-for="c in cameras" :key="c" type="button" class="gx-chip" :style="selectedCamera===c?'background:var(--primary);color:var(--on-primary);':''" @click="selectedCamera=c; playSegment()">{{ c }}</button>
|
||||
<button type="button" class="gx-btn" @click="downloadRoute"><i class="bi bi-download"></i> Download</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { navigate, store } from "../store.js"
|
||||
import {
|
||||
applyParamChange, countAdvancedHiddenByDeveloperMode, GALAXY_DEVELOPER_MODE_KEY, isSettingVisible,
|
||||
slugifySectionName,
|
||||
} from "../params.js"
|
||||
import { SettingTree } from "../components/SettingTree.js"
|
||||
import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
|
||||
import { GalaxySection } from "../components/GalaxySection.js"
|
||||
import { DevModeBanner } from "../components/DevModeBanner.js"
|
||||
|
||||
export const Settings = {
|
||||
name: "Settings",
|
||||
components: { SettingTree, GalaxyToggleCard, GalaxySection, DevModeBanner },
|
||||
data() {
|
||||
return {
|
||||
layout: [],
|
||||
values: {},
|
||||
expanded: {},
|
||||
loading: true,
|
||||
activeSectionSlug: "",
|
||||
defaultSectionSlug: "lateral-steering",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
devModeOn() { return !!this.values[GALAXY_DEVELOPER_MODE_KEY] },
|
||||
route() { return store.route },
|
||||
sections() {
|
||||
return this.layout
|
||||
.filter((s) => s.name !== "Model & Customization")
|
||||
.map((s) => ({
|
||||
...s,
|
||||
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values)),
|
||||
slug: slugifySectionName(s.name),
|
||||
}))
|
||||
.filter((s) => s.params.length > 0)
|
||||
},
|
||||
activeSection() {
|
||||
return this.sections.find((s) => s.slug === this.activeSectionSlug) || this.sections[0]
|
||||
},
|
||||
hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) },
|
||||
searchActive() { return !!this.searchTerm },
|
||||
searchTerm: {
|
||||
get() { return store.search },
|
||||
set(v) { store.search = v },
|
||||
},
|
||||
searchResults() {
|
||||
if (!this.searchActive) return []
|
||||
return this.sections
|
||||
.map((s) => ({ ...s, matches: s.params.filter((p) => this.matchesFilter(p)) }))
|
||||
.filter((s) => s.matches.length > 0)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
try {
|
||||
const [layout, values, defaults] = await Promise.all([
|
||||
api.getLayout(), api.getParams(), api.getDefaults(),
|
||||
])
|
||||
this.layout = layout
|
||||
this.values = values || {}
|
||||
this.defaults = defaults || {}
|
||||
if (!this.activeSectionSlug && this.sections.length) {
|
||||
const preferred = this.sections.find((s) => s.slug === this.defaultSectionSlug)
|
||||
this.activeSectionSlug = (preferred || this.sections[0]).slug
|
||||
}
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to load settings: " + (e?.message || e), "error")
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
onParamChange(patch) {
|
||||
this.values = applyParamChange(this.values, patch)
|
||||
},
|
||||
toggleManage(key) {
|
||||
const next = !this.expanded[key]
|
||||
this.expanded = { ...this.expanded, [key]: next }
|
||||
const base = "/settings/" + this.activeSectionSlug
|
||||
window.location.hash = next ? `${base}?open=${encodeURIComponent(key)}` : base
|
||||
},
|
||||
matchesFilter(p) {
|
||||
if (!this.searchTerm) return true
|
||||
const q = this.searchTerm.toLowerCase()
|
||||
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
|
||||
},
|
||||
selectSection(slug) {
|
||||
if (slug !== this.activeSectionSlug) navigate("/settings/" + slug)
|
||||
},
|
||||
applyRouteSection() {
|
||||
const route = store.route
|
||||
if (!route.startsWith("/settings/")) return
|
||||
const slug = route.replace(/^\/settings\/?/, "").split("?")[0].split("/")[0]
|
||||
if (slug && this.sections.some((s) => s.slug === slug)) this.activeSectionSlug = slug
|
||||
if (store.params.open) this.expanded = { ...this.expanded, [store.params.open]: true }
|
||||
},
|
||||
lockReason(param) {
|
||||
if (param?.requires_offroad && this.values.IsOnroad) return "This setting can only be changed while parked."
|
||||
if (param?.requires_parked && !this.values.VehicleParked) return "This setting can only be changed while the vehicle is in Park."
|
||||
if (param?.disabled_when_key_true && this.values[param.disabled_when_key_true]) return param.disabled_reason || "Disabled by another setting."
|
||||
if (param?.requires_nonempty_key) {
|
||||
const val = this.values[param.requires_nonempty_key]
|
||||
if (!val || val === "{}" || val === "") return param.disabled_reason || "Required configuration missing."
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
route() { this.applyRouteSection() },
|
||||
devModeOn() { this.load() },
|
||||
},
|
||||
async mounted() {
|
||||
await this.load()
|
||||
this.applyRouteSection()
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<h2 style="margin-top:0;">Toggles</h2>
|
||||
|
||||
<DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" />
|
||||
|
||||
<div v-if="loading" class="gx-loading">Loading configuration...</div>
|
||||
|
||||
<template v-else-if="sections.length">
|
||||
<div v-if="searchActive">
|
||||
<div class="gx-card">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-search"></i>
|
||||
<span class="gx-section__title">{{ searchResults.reduce((n, s) => n + s.matches.length, 0) }} result(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-for="section in searchResults" :key="section.slug">
|
||||
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
|
||||
<template v-for="p in section.matches" :key="p.key">
|
||||
<GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
|
||||
@change="onParamChange" />
|
||||
</template>
|
||||
</GalaxySection>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="gx-tabs" style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;">
|
||||
<button v-for="s in sections" :key="s.slug" type="button"
|
||||
class="gx-chip" :style="s.slug === activeSection.slug ? 'background: var(--primary); color: var(--on-primary);' : 'background: var(--surface-variant); color: var(--on-surface-variant); cursor:pointer;'"
|
||||
@click="selectSection(s.slug)">
|
||||
{{ s.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="gx-card">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi" :class="activeSection.icon"></i>
|
||||
<span class="gx-section__title">{{ activeSection.name }}</span>
|
||||
</div>
|
||||
<SettingTree :params="activeSection.params" :parent-key="null" :values="values"
|
||||
:expanded="expanded" :lock-reason="lockReason" @change="onParamChange" @manage="toggleManage" />
|
||||
<div v-if="!activeSection.params.length" class="gx-empty">No settings in this section.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="gx-empty">No settings available.</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
||||
import { GalaxySection } from "../components/GalaxySection.js"
|
||||
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
|
||||
|
||||
function shortCommit(commit) {
|
||||
return String(commit || "").slice(0, 10) || "—"
|
||||
}
|
||||
|
||||
export const SystemTools = {
|
||||
name: "SystemTools",
|
||||
components: { GalaxySection, GalaxyEmbed },
|
||||
data() {
|
||||
return {
|
||||
branches: [],
|
||||
currentBranch: "",
|
||||
branchLoading: true,
|
||||
fastStatus: null,
|
||||
checkedForUpdates: false,
|
||||
busy: "",
|
||||
}
|
||||
},
|
||||
created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() },
|
||||
mounted() { this.loadBranches() },
|
||||
beforeUnmount() { this.poll?.destroy() },
|
||||
computed: {
|
||||
updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
|
||||
},
|
||||
methods: {
|
||||
shortCommit,
|
||||
async loadBranches() {
|
||||
try {
|
||||
const data = await api.getUpdateBranches()
|
||||
this.branches = Array.isArray(data?.branches) ? data.branches : []
|
||||
this.currentBranch = data?.currentBranch || ""
|
||||
this.isOnroad = !!data?.isOnroad
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to load update info.", "error")
|
||||
} finally {
|
||||
this.branchLoading = false
|
||||
}
|
||||
},
|
||||
async loadFastStatus() {
|
||||
try { this.fastStatus = await api.getUpdateFastStatus() } catch (e) { this.fastStatus = null }
|
||||
},
|
||||
async backupToggles() {
|
||||
try {
|
||||
const blob = await api.backupToggles()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "toggle-backup.json"
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
showSnackbar("Toggle backup downloaded.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Backup failed.", "error")
|
||||
}
|
||||
},
|
||||
onRestoreFile(e) {
|
||||
const file = e.target.files[0]
|
||||
e.target.value = ""
|
||||
if (!file) return
|
||||
if (file.size > 5_000_000) { showSnackbar("That toggle backup file is too large.", "error"); return }
|
||||
file.text().then((text) => {
|
||||
let data
|
||||
try { data = JSON.parse(text) } catch { showSnackbar("That file is not a valid toggle backup.", "error"); return }
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) { showSnackbar("That file is not a valid toggle backup.", "error"); return }
|
||||
api.restoreToggles(data).then((res) => {
|
||||
showSnackbar(res?.message || "Toggles restored!")
|
||||
}).catch((err) => showSnackbar(err?.message || "Failed to restore toggles.", "error"))
|
||||
})
|
||||
},
|
||||
async resetDefault() {
|
||||
if (!(await GalaxyConfirm({ title: "Reset toggles to default?", message: "This resets all toggles to their default values and reboots.", confirmLabel: "Reset", danger: true }))) return
|
||||
try {
|
||||
await api.resetTogglesDefault()
|
||||
showSnackbar("Resetting toggles to default... rebooting.")
|
||||
} catch (e) {
|
||||
showSnackbar("Reset failed.", "error")
|
||||
}
|
||||
},
|
||||
onBranchSelect(e) {
|
||||
const branch = e.target.value
|
||||
e.target.value = this.currentBranch || ""
|
||||
this.switchBranch(branch)
|
||||
},
|
||||
async switchBranch(branch) {
|
||||
if (!branch || branch === this.currentBranch) return
|
||||
if (!(await GalaxyConfirm({ title: "Switch branch?", message: `Switch to ${branch} and update?`, confirmLabel: "Switch" }))) return
|
||||
try {
|
||||
await api.setUpdateBranch(branch)
|
||||
showSnackbar(`Switching to ${branch}...`)
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Switch failed.", "error")
|
||||
}
|
||||
},
|
||||
async checkUpdates() {
|
||||
if (this.busy) return
|
||||
this.busy = "check"
|
||||
try {
|
||||
await this.loadFastStatus()
|
||||
this.checkedForUpdates = true
|
||||
const st = this.fastStatus
|
||||
if (st?.running) showSnackbar("An update is already running.")
|
||||
else if (st?.updateAvailable) showSnackbar(st?.message || "Update available.")
|
||||
else showSnackbar(st?.message || "No update available — you're up to date.")
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to check for updates.", "error")
|
||||
} finally {
|
||||
this.busy = ""
|
||||
}
|
||||
},
|
||||
async applyFastUpdate() {
|
||||
if (this.busy || this.isOnroad) return
|
||||
if (this.fastStatus?.running) { showSnackbar("Fast update is already running."); return }
|
||||
if (!this.checkedForUpdates || !this.updateAvailable) {
|
||||
showSnackbar("No update available. Run \"Check for Updates\" first.", "error")
|
||||
return
|
||||
}
|
||||
const st = this.fastStatus
|
||||
const confirmed = await GalaxyConfirm({
|
||||
title: "Update available",
|
||||
message: `Fast update to the latest commit on ${st?.branch || "this branch"}.\n\nYour device will reboot when the update is done.`,
|
||||
confirmLabel: "Update & Reboot",
|
||||
danger: true,
|
||||
})
|
||||
if (!confirmed) return
|
||||
await this.runUpdate("fast")
|
||||
},
|
||||
async runUpdate(action) {
|
||||
if (this.busy) return
|
||||
this.busy = action
|
||||
try {
|
||||
if (action === "rollback") {
|
||||
const st = this.fastStatus
|
||||
if (st && !st.rollbackAvailable) {
|
||||
showSnackbar("No previous installed version is available to roll back to.", "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (action !== "fast") {
|
||||
const actionLabels = { recover: "Recover the interrupted update?", rollback: "Roll back to the previous installed version?" }
|
||||
if (!(await GalaxyConfirm({ title: actionLabels[action] || "Continue?", message: "Your device will reboot when the operation is done.", confirmLabel: "Continue", danger: true }))) return
|
||||
}
|
||||
const fn = action === "fast" ? api.updateFast : action === "recover" ? api.updateRecover : api.updateRollback
|
||||
const payload = await fn()
|
||||
showSnackbar(payload?.message || "Update started.")
|
||||
await this.loadFastStatus()
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Update failed.", "error")
|
||||
} finally {
|
||||
this.busy = ""
|
||||
}
|
||||
},
|
||||
async factoryReset() {
|
||||
if (!(await GalaxyConfirm({ title: "Factory reset?", message: "This wipes params, backups, themes, models, maps, and route data, then reboots. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
|
||||
try {
|
||||
await api.factoryReset()
|
||||
showSnackbar("Factory resetting...")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Factory reset failed.", "error")
|
||||
}
|
||||
},
|
||||
async saveMe() {
|
||||
if (!(await GalaxyConfirm({ title: "SAVE ME", message: "This will factory reset the device by wiping params, backups, themes, models, maps, and route data. The device will reboot when the wipe is complete. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
|
||||
try {
|
||||
await api.factoryReset()
|
||||
showSnackbar("SAVE ME initiated — factory resetting...")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Factory reset failed.", "error")
|
||||
}
|
||||
},
|
||||
async deleteAllDrivingRoutes() {
|
||||
if (!(await GalaxyConfirm({ title: "Delete All Driving Routes", message: "This permanently deletes all local routes from standard, high-resolution, and alternate footage storage. It does not reset settings or reboot the device.", confirmLabel: "Delete Routes", danger: true }))) return
|
||||
try {
|
||||
const payload = await api.deleteAllRoutes(true)
|
||||
showSnackbar(payload?.message || "All local driving routes deleted.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Failed to delete driving routes.", "error")
|
||||
}
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<h2 style="margin-top:0;">System Tools</h2>
|
||||
|
||||
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat">
|
||||
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn" @click="backupToggles"><i class="bi bi-download"></i> Backup Toggles</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="$refs.restoreInput.click()"><i class="bi bi-upload"></i> Restore Toggles</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="resetDefault">Reset to Default</button>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="saveMe">SAVE ME</button>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllDrivingRoutes">Delete All Driving Routes</button>
|
||||
<input ref="restoreInput" type="file" accept=".json" style="display:none;" @change="onRestoreFile" />
|
||||
</div>
|
||||
<GalaxyEmbed src="/manage_toggles" title="Backup & Restore" style="min-height:60vh; margin: var(--sp-3);" />
|
||||
</GalaxySection>
|
||||
|
||||
<GalaxySection title="Software & Updates" icon="bi-arrow-up-circle">
|
||||
<div style="padding: var(--sp-3);">
|
||||
<div v-if="branchLoading" class="gx-loading">Loading update info...</div>
|
||||
<template v-else>
|
||||
<p v-if="isOnroad" style="color:var(--text-muted);">Updates and branch switching are only available while offroad.</p>
|
||||
|
||||
<div v-if="fastStatus" class="gx-card" style="margin-bottom:12px;">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
<span class="gx-section__title">Update Status</span>
|
||||
<span v-if="fastStatus.running" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ fastStatus.progressPercent }}%</span>
|
||||
<span v-else-if="fastStatus.updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
|
||||
<span v-else class="gx-chip">Up to date</span>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:grid; gap:6px;">
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
|
||||
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
|
||||
<div v-if="fastStatus.message" class="gx-row__desc">{{ fastStatus.message }}</div>
|
||||
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-row__desc" style="color:var(--warning);">{{ fastStatus.warning }}</div>
|
||||
<div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;">
|
||||
<div v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-row__desc" style="color:var(--warning);">⚠ {{ w }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:12px 0 8px;">Switch branch</h4>
|
||||
<div class="gx-row" style="border-top:none; padding:4px 0;">
|
||||
<select class="gx-field gx-field--full" :disabled="!!isOnroad" @change="onBranchSelect">
|
||||
<option v-if="!branches.length" value="">No branches available</option>
|
||||
<option v-for="b in branches" :key="b" :value="b" :selected="b === currentBranch">{{ b === currentBranch ? b + ' (current)' : b }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || !!fastStatus?.running" @click="checkUpdates">
|
||||
<i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i>
|
||||
<i v-else class="bi bi-search"></i> {{ busy === 'check' ? 'Checking...' : 'Check for Updates' }}
|
||||
</button>
|
||||
<button type="button" class="gx-btn" :disabled="!updateAvailable || !!busy || isOnroad" @click="applyFastUpdate">
|
||||
<i class="bi bi-arrow-up-circle"></i> {{ busy === 'fast' ? 'Updating...' : 'Update Now' }}
|
||||
</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('recover')">Recover</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('rollback')">Rollback</button>
|
||||
</div>
|
||||
<p v-if="checkedForUpdates && !updateAvailable && !fastStatus?.running" style="color:var(--text-muted); margin:8px 0 0;">
|
||||
The device is up to date. Update becomes available only after a check finds a newer commit.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</GalaxySection>
|
||||
|
||||
<GalaxySection title="Danger Zone" icon="bi-exclamation-triangle">
|
||||
<div style="padding: var(--sp-3);">
|
||||
<p style="color: var(--text-muted);">Last resort only. This wipes params, backups, themes, models, maps, and route data, then reboots the device.</p>
|
||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="factoryReset">Factory Reset Device</button>
|
||||
</div>
|
||||
</GalaxySection>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { store } from "../store.js"
|
||||
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
|
||||
|
||||
export const ToolEmbed = {
|
||||
name: "ToolEmbed",
|
||||
components: { GalaxyEmbed },
|
||||
computed: {
|
||||
src() { return store.params.src || "/tools" },
|
||||
title() {
|
||||
const map = {
|
||||
"/manage_models": "Model Manager",
|
||||
"/galaxy": "Galaxy",
|
||||
"/sentry": "Sentry Mode",
|
||||
"/plots": "Live Plots",
|
||||
"/download_speed_limits": "Download Speed Limits",
|
||||
"/testing_ground": "Testing Ground",
|
||||
"/theme_maker": "Theme Maker",
|
||||
"/troubleshoot": "Troubleshoot",
|
||||
"/manage_tmux": "Tmux Log",
|
||||
"/manage_toggles": "Backup and Restore",
|
||||
"/manage_updates": "Software",
|
||||
"/manage_error_logs": "Error Logs",
|
||||
"/bluetooth": "Bluetooth",
|
||||
"/wheel-controls": "Controllers",
|
||||
"/vehicle_features": "Vehicle Features",
|
||||
"/manage_v_asm": "V-Adj Spot Monitor",
|
||||
"/manage_pip_sidecam": "PiP Side Camera",
|
||||
"/manage_doors": "Lock/Unlock Doors",
|
||||
"/manage_tsk": "Toyota Security Keys",
|
||||
"/set_navigation_destination": "Navigation Destination",
|
||||
"/manage_navigation_keys": "App Keys",
|
||||
"/manage_maps": "Maps",
|
||||
}
|
||||
return map[this.src] || "Tool"
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="gx-view">
|
||||
<div class="gx-section__header" style="padding: var(--sp-3) var(--sp-4);">
|
||||
<i class="bi bi-grid"></i>
|
||||
<span class="gx-section__title">{{ title }}</span>
|
||||
</div>
|
||||
<GalaxyEmbed :src="src" :title="title" forward-nav />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { navigate, toolHref } from "../store.js"
|
||||
|
||||
const TOOLS = [
|
||||
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2", desc: "Pairing & remote access" },
|
||||
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" },
|
||||
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" },
|
||||
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" },
|
||||
{ name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video", desc: "Adjust PiP side-camera window" },
|
||||
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow", desc: "Live telemetry plots" },
|
||||
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" },
|
||||
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" },
|
||||
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2", desc: "Experiments & testing" },
|
||||
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill", desc: "Customize the look" },
|
||||
{ name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed behaviour" },
|
||||
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box", desc: "Adjust spot-monitor window" },
|
||||
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" },
|
||||
].sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
export const Tools = {
|
||||
name: "Tools",
|
||||
data() { return { TOOLS } },
|
||||
methods: {
|
||||
open(t) {
|
||||
navigate(toolHref(t.link))
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<h2 style="margin-top:0;">Tools</h2>
|
||||
<div class="gx-grid">
|
||||
<button v-for="t in TOOLS" :key="t.link" type="button" class="gx-tile" @click="open(t)">
|
||||
<i class="bi" :class="t.icon"></i>
|
||||
<span>{{ t.name }}</span>
|
||||
<small style="color: var(--text-muted);">{{ t.desc }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
|
||||
|
||||
export const Tuning = {
|
||||
name: "Tuning",
|
||||
components: { GalaxyEmbed },
|
||||
template: `
|
||||
<div class="gx-view">
|
||||
<div class="gx-section__header" style="padding: var(--sp-3) var(--sp-4);">
|
||||
<i class="bi bi-sign-turn-right"></i>
|
||||
<span class="gx-section__title">Tuning & Maneuvers</span>
|
||||
</div>
|
||||
<GalaxyEmbed src="/tuning" title="Tuning" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { navigate, toolHref } from "../store.js"
|
||||
import { WheelControls } from "../components/WheelControls.js"
|
||||
import { BluetoothPanel } from "../components/BluetoothPanel.js"
|
||||
import { GalaxySection } from "../components/GalaxySection.js"
|
||||
|
||||
const FEATURES = [
|
||||
{ key: "doors", name: "Lock/Unlock Doors", icon: "bi-door-closed", desc: "Send lock or unlock commands remotely to your vehicle.", embed: "/manage_doors" },
|
||||
{ key: "tsk", name: "Toyota Security Keys", icon: "bi-key-fill", desc: "Manage and apply security keys for secOC protected devices.", embed: "/manage_tsk" },
|
||||
]
|
||||
|
||||
export const Vehicle = {
|
||||
name: "Vehicle",
|
||||
components: { WheelControls, BluetoothPanel, GalaxySection },
|
||||
data() {
|
||||
return {
|
||||
features: FEATURES,
|
||||
featureStatus: {},
|
||||
busy: "",
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
statusOf(key) { return this.featureStatus[key] || "untested" },
|
||||
async openFeature(f) {
|
||||
if (this.busy) return
|
||||
if (this.statusOf(f.key) === "denied") {
|
||||
showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
|
||||
return
|
||||
}
|
||||
if (this.statusOf(f.key) === "allowed") {
|
||||
navigate(toolHref(f.embed))
|
||||
return
|
||||
}
|
||||
this.busy = f.key
|
||||
try {
|
||||
const data = await api.carFeaturesCheck(f.key)
|
||||
const allowed = !!data?.result
|
||||
this.featureStatus = { ...this.featureStatus, [f.key]: allowed ? "allowed" : "denied" }
|
||||
if (allowed) navigate(toolHref(f.embed))
|
||||
else showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
|
||||
} catch (e) {
|
||||
this.featureStatus = { ...this.featureStatus, [f.key]: "denied" }
|
||||
showSnackbar("Could not check vehicle compatibility.", "error")
|
||||
} finally {
|
||||
this.busy = ""
|
||||
}
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<h2 style="margin-top:0;">Vehicle Controls</h2>
|
||||
|
||||
<GalaxySection title="Controllers" icon="bi-controller">
|
||||
<WheelControls />
|
||||
</GalaxySection>
|
||||
|
||||
<GalaxySection title="Bluetooth" icon="bi-bluetooth">
|
||||
<BluetoothPanel />
|
||||
</GalaxySection>
|
||||
|
||||
<GalaxySection title="Vehicle Features" icon="bi-check2-square">
|
||||
<div style="padding: var(--sp-3); display:grid; gap:8px;">
|
||||
<button v-for="f in features" :key="f.key" type="button"
|
||||
class="gx-row" style="width:100%; border:none; background:transparent; color:inherit; cursor:pointer; text-align:left;"
|
||||
@click="openFeature(f)">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label"><i class="bi" :class="f.icon" style="margin-right:6px; color:var(--primary);"></i>{{ f.name }}</span>
|
||||
<span class="gx-row__desc">{{ f.desc }}</span>
|
||||
</div>
|
||||
<span v-if="busy === f.key" class="gx-chip" style="background:var(--surface-variant);">Checking...</span>
|
||||
<span v-else-if="statusOf(f.key) === 'denied'" class="gx-chip" style="background:var(--error);">Not supported</span>
|
||||
<i v-else class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
|
||||
</button>
|
||||
<p style="color:var(--text-muted); margin:0;">These features verify vehicle compatibility when launched.</p>
|
||||
</div>
|
||||
</GalaxySection>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Galaxy",
|
||||
"short_name": "Galaxy",
|
||||
"description": "Control and configure your openpilot device from anywhere.",
|
||||
"icons": [
|
||||
{ "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/assets/images/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
||||
],
|
||||
"id": "46bf2df73deba8e1512c35de",
|
||||
"start_url": "/mobile/",
|
||||
"scope": "/",
|
||||
"background_color": "#06060f",
|
||||
"theme_color": "#8b6cc5",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"orientation": "portrait-primary"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,15 @@
|
||||
<script src="/assets/js/snackbar.js"></script>
|
||||
|
||||
<title>Galaxy</title>
|
||||
|
||||
<script>
|
||||
const _inEmbed =
|
||||
new URLSearchParams(window.location.search).has("embedded") ||
|
||||
(window.self !== window.top);
|
||||
if (_inEmbed) {
|
||||
document.documentElement.classList.add("embedded");
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
DEVICE_SETTINGS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.js"
|
||||
DEVICE_SETTINGS_CSS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.css"
|
||||
|
||||
|
||||
def _device_settings():
|
||||
return DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_device_settings_surfaces_hidden_advanced_settings_count():
|
||||
source = _device_settings()
|
||||
|
||||
assert "countAdvancedHiddenByDeveloperMode" in source
|
||||
assert "isAdvancedHiddenByDeveloperMode" in source
|
||||
assert "hiddenAdvancedCount" in source
|
||||
|
||||
|
||||
def test_developer_mode_notice_is_rendered_when_advanced_settings_hidden():
|
||||
source = _device_settings()
|
||||
|
||||
assert "ds-dev-mode-notice" in source
|
||||
assert "advanced setting" in source
|
||||
assert "Enable Developer Mode" in source
|
||||
|
||||
|
||||
def test_developer_mode_notice_navigates_to_developer_section():
|
||||
source = _device_settings()
|
||||
|
||||
assert 'window.__theGalaxyNavigate("/device_settings/developer")' in source
|
||||
|
||||
|
||||
def test_advanced_settings_hidden_count_shown_in_status_bar():
|
||||
source = _device_settings()
|
||||
|
||||
assert "advanced hidden" in source
|
||||
|
||||
|
||||
def test_device_settings_uses_the_params_api_and_layout_json():
|
||||
source = _device_settings()
|
||||
|
||||
assert 'fetch("/api/params/all")' in source
|
||||
assert 'fetch("/api/params/defaults")' in source
|
||||
assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source
|
||||
|
||||
|
||||
def test_developer_mode_notice_has_styles():
|
||||
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert ".ds-dev-mode-notice" in css
|
||||
assert ".ds-dev-mode-notice-btn" in css
|
||||
@@ -0,0 +1,437 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
UI_ROOT = REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile"
|
||||
GALAXY_PY = REPO_ROOT / "starpilot/system/the_galaxy/the_galaxy.py"
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (UI_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_ui_app_shell_files_exist():
|
||||
required = [
|
||||
"index.html",
|
||||
"manifest.json",
|
||||
"css/material.css",
|
||||
"js/app.js",
|
||||
"js/store.js",
|
||||
"js/api.js",
|
||||
"js/params.js",
|
||||
"js/components/AppShell.js",
|
||||
"js/components/GalaxyModal.js",
|
||||
"js/components/GalaxySection.js",
|
||||
"js/components/GalaxyEmbed.js",
|
||||
"js/components/GalaxyToggleCard.js",
|
||||
"js/components/SettingTree.js",
|
||||
"js/components/ParamSections.js",
|
||||
"js/components/ManeuverCard.js",
|
||||
"js/components/WheelControls.js",
|
||||
"js/components/BluetoothPanel.js",
|
||||
"js/components/DevModeBanner.js",
|
||||
"js/composables.js",
|
||||
"js/views/Home.js",
|
||||
"js/views/Settings.js",
|
||||
"js/views/Tools.js",
|
||||
"js/views/Recordings.js",
|
||||
"js/views/Logs.js",
|
||||
"js/views/Tuning.js",
|
||||
"js/views/Navigation.js",
|
||||
"js/views/Vehicle.js",
|
||||
"js/views/SystemTools.js",
|
||||
]
|
||||
for rel in required:
|
||||
assert (UI_ROOT / rel).is_file(), f"missing new-UI file: {rel}"
|
||||
|
||||
|
||||
def test_ui_index_wires_vue_and_mount_point():
|
||||
index = _read("index.html")
|
||||
assert 'id="galaxy-app"' in index
|
||||
assert 'src="/assets/mobile/js/app.js"' in index
|
||||
assert '"vue": "/assets/vendor/vue/vue.esm-browser.js"' in index
|
||||
|
||||
|
||||
def test_ui_uses_same_backend_endpoints():
|
||||
settings = _read("js/views/Settings.js")
|
||||
params = _read("js/params.js")
|
||||
api = _read("js/api.js")
|
||||
|
||||
# Settings fetches the exact same layout JSON + params API the original UI used.
|
||||
assert '/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in settings or \
|
||||
'/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in api
|
||||
assert '"/api/params/all"' in api
|
||||
assert '"/api/params"' in api
|
||||
assert '"/api/params/defaults"' in api
|
||||
|
||||
|
||||
def test_ui_ports_developer_mode_gating():
|
||||
params = _read("js/params.js")
|
||||
assert "countAdvancedHiddenByDeveloperMode" in params
|
||||
assert "isSettingVisible" in params
|
||||
assert "isAdvancedHiddenByDeveloperMode" in params
|
||||
|
||||
|
||||
def test_ui_restores_hierarchical_sub_toggle_rendering():
|
||||
# Children must nest under parents via the recursive SettingTree, gated on
|
||||
# the parent being enabled AND expanded (classic renderSettingTree contract).
|
||||
params = _read("js/params.js")
|
||||
settings = _read("js/views/Settings.js")
|
||||
tree = _read("js/components/SettingTree.js")
|
||||
|
||||
assert "buildRenderTree" in params
|
||||
assert "isParamEnabledForChildren" in params
|
||||
assert "hasChildParams" in params
|
||||
|
||||
assert "SettingTree" in settings
|
||||
assert '<SettingTree :params="activeSection.params"' in settings
|
||||
|
||||
# SettingTree recursively reveals children; subpanels are collapsed by default
|
||||
# (classic Galaxy behavior) and expand only when the user taps Manage/Close.
|
||||
assert "showChildren(p)" in tree
|
||||
assert "enabledForChildren(p) && this.isExpanded(p)" in tree
|
||||
assert "gx-tree-node--child" in tree
|
||||
assert "gx-collapse" in tree
|
||||
# It self-references so arbitrary nesting depth (grandchildren, etc.) works.
|
||||
assert "<SettingTree" in tree
|
||||
|
||||
|
||||
def test_ui_ports_all_tool_views():
|
||||
# Endpoint usage may live in a view or in a reusable component it composes.
|
||||
checks = {
|
||||
"js/views/Recordings.js": ["/api/routes", "getRoutesStream", "getRouteLogs"],
|
||||
"js/views/Logs.js": ["getErrorLogs", "tmuxSnapshot", "runTroubleshoot"],
|
||||
"js/views/Tuning.js": ["GalaxyEmbed", 'src="/tuning"'],
|
||||
"js/views/Navigation.js": ["getNavigation", "setNavigation"],
|
||||
"js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"],
|
||||
"js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"],
|
||||
"js/components/WheelControls.js": ["getWheelControlsStatus"],
|
||||
"js/components/BluetoothPanel.js": ["getBluetoothStatus"],
|
||||
}
|
||||
for rel, endpoints in checks.items():
|
||||
src = _read(rel)
|
||||
assert src, f"missing file: {rel}"
|
||||
for ep in endpoints:
|
||||
assert ep in src, f"{rel} should use api.{ep}"
|
||||
vehicle = _read("js/views/Vehicle.js")
|
||||
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle and "carFeaturesCheck" in vehicle
|
||||
|
||||
|
||||
def test_ui_routes_ported_views_natively_no_classic_fallback():
|
||||
app = _read("js/app.js")
|
||||
shell = _read("js/components/AppShell.js")
|
||||
tools = _read("js/views/Tools.js")
|
||||
home = _read("js/views/Home.js")
|
||||
|
||||
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
|
||||
assert view in app, f"app.js should register {view}"
|
||||
|
||||
# Ported routes must resolve natively in the Vue app (zero /classic redirect).
|
||||
for route in ["/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system"]:
|
||||
assert route in shell, f"AppShell should route {route} natively"
|
||||
assert route in app, f"app.js should resolve {route} natively"
|
||||
# Tools grid routes the native categories (Recordings lives in the bottom nav
|
||||
# and is intentionally absent from the Tools page).
|
||||
for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/system"]:
|
||||
assert tool in tools, f"Tools grid should route {tool} natively"
|
||||
# V-ASM Spot Monitor and PiP Side Camera use the classic page-in-page embeds
|
||||
# instead of the removed native Annotation Tool.
|
||||
assert "/manage_v_asm" in tools and "/manage_pip_sidecam" in tools
|
||||
# Unmigrated tools are embedded (ToolEmbed), never a full-page redirect.
|
||||
assert "return ToolEmbed" in app
|
||||
assert "ToolEmbed" in app
|
||||
# Home renders the classic dashboard as a shared page-in-page embed.
|
||||
assert 'src="/classic"' in home and "GalaxyEmbed" in home
|
||||
# Neither the shell, tools grid, nor home tiles ever redirect out of the UI.
|
||||
for src in [shell, tools, home, _read("js/views/ToolEmbed.js"), _read("js/store.js")]:
|
||||
assert "window.location.href" not in src
|
||||
|
||||
|
||||
def test_ui_embeds_unmigrated_tools_inapp():
|
||||
# All page-in-page embeds go through the single shared GalaxyEmbed component.
|
||||
embed_comp = _read("js/components/GalaxyEmbed.js")
|
||||
assert "iframe" in embed_comp and "gx-embed__frame" in embed_comp
|
||||
tool = _read("js/views/ToolEmbed.js")
|
||||
assert "GalaxyEmbed" in tool and "forward-nav" in tool
|
||||
store = _read("js/store.js")
|
||||
assert "toolHref" in store and "/embed?src=" in store
|
||||
shell = _read("js/components/AppShell.js")
|
||||
assert "toolHref" in shell
|
||||
# Embed mode tags the iframe src so the classic SPA hides its sidebar/menu.
|
||||
assert "embedded=1" in embed_comp
|
||||
classic_index = (REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html").read_text(encoding="utf-8")
|
||||
assert 'has("embedded")' in classic_index and 'classList.add("embedded")' in classic_index
|
||||
assert "window.self !== window.top" in classic_index
|
||||
|
||||
|
||||
def test_ui_numeric_toggles_are_sliders_with_default():
|
||||
card = _read("js/components/GalaxyToggleCard.js")
|
||||
# All numeric params render as a slider (no more +/- stepper or bare 0 button).
|
||||
assert "isSlider() { return this.isNumeric }" in card
|
||||
assert 'type="range"' in card
|
||||
assert "onSliderInput" in card and "onSliderCommit" in card
|
||||
# A Default (reset-to-stock) button is present for numerics.
|
||||
assert "resetToDefault" in card
|
||||
# The stepper/number-input UI is gone.
|
||||
assert 'type="number"' not in card
|
||||
assert "−{{ stepLabel() }}" not in card
|
||||
assert 'title="Set to zero"' not in card
|
||||
|
||||
|
||||
def test_ui_centralizes_api_and_uses_composables():
|
||||
api = _read("js/api.js")
|
||||
composables = _read("js/composables.js")
|
||||
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
|
||||
src = _read(f"js/views/{view}.js")
|
||||
# Views must not issue raw fetch() / hand-rolled polling / SSE.
|
||||
assert "fetch(" not in src.replace("api.", ""), f"{view} should not use raw fetch()"
|
||||
|
||||
# Shared polling + log streaming live in composables, not duplicated in views.
|
||||
assert "usePolling" in composables
|
||||
assert "useLogStream" in composables
|
||||
assert "usePolling" in _read("js/components/ManeuverCard.js")
|
||||
assert "usePolling" in _read("js/components/WheelControls.js")
|
||||
assert "useLogStream" in _read("js/views/Logs.js")
|
||||
|
||||
|
||||
def test_ui_schema_driven_param_engine_reused():
|
||||
# Tuning is a page-in-page embed of the classic /tuning SPA (which owns its
|
||||
# own tuning UI). Vehicle is a hub of Controllers/Bluetooth/Features — all
|
||||
# toggles live in the dedicated Settings view, so it must NOT render toggles.
|
||||
tuning = _read("js/views/Tuning.js")
|
||||
embed_comp = _read("js/components/GalaxyEmbed.js")
|
||||
assert 'src="/tuning"' in tuning and "GalaxyEmbed" in tuning, "Tuning should embed the classic tuning SPA"
|
||||
assert "embedded=1" in embed_comp
|
||||
vehicle = _read("js/views/Vehicle.js")
|
||||
assert "ParamSections" not in vehicle, "Vehicle must not render redundant toggles"
|
||||
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle
|
||||
assert "GalaxySection" in vehicle
|
||||
engine = _read("js/components/ParamSections.js")
|
||||
assert "SettingTree" in engine
|
||||
assert "isSettingVisible" in engine
|
||||
|
||||
|
||||
def test_ui_eliminates_slider_toggle_flicker():
|
||||
card = _read("js/components/GalaxyToggleCard.js")
|
||||
css = _read("css/material.css")
|
||||
|
||||
# Optimistic local preview + interacting guard. The value only commits when
|
||||
# the drag/keyboard interaction is RELEASED, so holding never drops it.
|
||||
assert "preview" in card
|
||||
assert "interacting" in card
|
||||
assert "onSliderCommit" in card
|
||||
assert "flushSlider" in card
|
||||
# No mid-drag auto-commit timer: holding still must NOT release/lock.
|
||||
assert "commitTimer" not in card
|
||||
assert "setTimeout" not in card
|
||||
# Release (change) and blur (keyboard) both flush the commit.
|
||||
assert "interacting = false" in card
|
||||
assert "onSliderBlur" in card
|
||||
assert "@blur=\"onSliderBlur\"" in card
|
||||
assert "currentValue() { return this.preview !== undefined ? this.preview : this.value }" in card
|
||||
|
||||
# Manage/Close affordance for parent toggles with nested children.
|
||||
assert "manageable" in card
|
||||
assert "manageOpen" in card
|
||||
assert "gx-manage-btn" in card
|
||||
|
||||
# Nested indentation / expand animation styling present.
|
||||
assert ".gx-tree-node--child" in css
|
||||
assert ".gx-manage-btn" in css
|
||||
assert ".gx-tree-children" in css
|
||||
|
||||
|
||||
def test_ui_developer_mode_banner_offers_unlock():
|
||||
banner = _read("js/components/DevModeBanner.js")
|
||||
assert "Enable Developer Mode" in banner
|
||||
assert 'navigate("/settings/developer")' in banner
|
||||
assert "advanced setting" in banner
|
||||
|
||||
|
||||
def test_ui_has_bottom_navigation_and_drawer():
|
||||
shell = _read("js/components/AppShell.js")
|
||||
# Exactly one navigation affordance: liquid-glass bottom nav (mobile) OR the
|
||||
# drawer hamburger (desktop). Mobile shows a back button instead.
|
||||
assert "liquid-glass-nav" in shell
|
||||
assert "nav-item" in shell
|
||||
assert "gx-menu-btn" in shell
|
||||
assert "gx-back-btn" in shell
|
||||
assert "goBack" in shell or "back()" in shell
|
||||
assert "gx-drawer" in shell
|
||||
assert "gx-appbar" in shell
|
||||
assert "Search settings" in shell
|
||||
|
||||
|
||||
def test_ui_search_visible_on_mobile_and_content_full_width():
|
||||
css = _read("css/material.css")
|
||||
# Search must NOT be hidden on mobile (regression: it was display:none <600px).
|
||||
assert ".gx-appbar__search" in css
|
||||
# A single breakpoint picks mobile (bottom nav + back) vs desktop (drawer).
|
||||
assert ".liquid-glass-nav { display: flex; }" in css
|
||||
assert ".gx-menu-btn { display: none; }" in css
|
||||
assert ".gx-back-btn { display: inline-flex; }" in css
|
||||
# Content + embedded tools fill the available width (no 760px cap).
|
||||
assert "max-width: none" in css
|
||||
# Liquid Glass styling is present.
|
||||
assert "--glass-bg" in css
|
||||
assert "backdrop-filter" in css
|
||||
|
||||
|
||||
def test_ui_glass_nav_single_breakpoint_no_dual_nav():
|
||||
css = _read("css/material.css")
|
||||
assert ".liquid-glass-nav" in css
|
||||
assert "@media (min-width: 768px)" in css
|
||||
assert ".liquid-glass-nav { display: none; }" in css
|
||||
assert ".gx-back-btn { display: none; }" in css
|
||||
assert ".gx-menu-btn { display: inline-flex; }" in css
|
||||
|
||||
|
||||
def test_ui_settings_deep_links_and_dev_mode_updates():
|
||||
settings = _read("js/views/Settings.js")
|
||||
# Route is reactive via a computed (not the broken this.store watch), so a
|
||||
# Developer-Mode navigation updates the page without a refresh.
|
||||
assert "route() { return store.route }" in settings
|
||||
assert "route() { this.applyRouteSection() }" in settings
|
||||
# Selecting a section + opening a subpanel update the URL (deep-linkable).
|
||||
assert 'navigate("/settings/" + slug)' in settings
|
||||
assert "?open=" in settings
|
||||
# Turning Developer Mode on reloads data so newly-visible sections (e.g.
|
||||
# Favorites) render immediately.
|
||||
assert "devModeOn() { this.load() }" in settings
|
||||
|
||||
|
||||
def test_ui_galaxy_background_is_css_only_and_lightweight():
|
||||
index = _read("index.html")
|
||||
css = _read("css/material.css")
|
||||
# Background is a single fixed layer, pure CSS (no canvas/WebGL/images).
|
||||
assert 'id="galaxy-bg"' in index
|
||||
assert "#galaxy-bg" in css
|
||||
assert "position: fixed" in css
|
||||
assert "radial-gradient" in css
|
||||
# Cheap GPU-friendly transforms + stars; disabled for reduced-motion.
|
||||
assert "--gx-para-x" in css
|
||||
assert "prefers-reduced-motion" in css
|
||||
# App content sits above the background layer.
|
||||
assert ".gx-app" in css and "z-index: 1" in css
|
||||
|
||||
|
||||
def test_galaxy_py_serves_classic_at_root_and_new_ui_at_mobile():
|
||||
source = GALAXY_PY.read_text(encoding="utf-8")
|
||||
# The classic Galaxy SPA is the default landing at / (original behaviour).
|
||||
assert '@app.route("/", methods=["GET"])' in source
|
||||
assert 'render_template("index.html")' in source
|
||||
# Classic also stays reachable at /classic (page-in-page embed target).
|
||||
assert '@app.route("/classic", methods=["GET"])' in source
|
||||
# The modern Vue UI is served at /mobile (and /ui), not the root.
|
||||
assert '@app.route("/mobile", methods=["GET"])' in source
|
||||
assert '@app.route("/ui", methods=["GET"])' in source
|
||||
assert 'Path(app.static_folder) / "mobile" / "index.html"' in source
|
||||
|
||||
|
||||
def test_ui_manifest_is_valid_pwa_manifest():
|
||||
manifest = json.loads((UI_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["display"] == "standalone"
|
||||
assert manifest["name"]
|
||||
assert manifest["icons"]
|
||||
assert manifest["start_url"] == "/mobile/"
|
||||
|
||||
|
||||
def _node_exe():
|
||||
candidates = [
|
||||
shutil.which("node"),
|
||||
"/mnt/c/Program Files/nodejs/node.exe",
|
||||
]
|
||||
for path in candidates:
|
||||
if path and Path(path).exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.skipif(_node_exe() is None, reason="no node.js runtime available")
|
||||
def test_ui_ported_param_logic_runs_and_passes(tmp_path):
|
||||
node = _node_exe()
|
||||
params_src = _read("js/params.js")
|
||||
params_dst = tmp_path / "params.mjs"
|
||||
params_dst.write_text(params_src, encoding="utf-8")
|
||||
|
||||
script = tmp_path / "pcheck.mjs"
|
||||
script.write_text(
|
||||
"""
|
||||
import * as P from "./params.mjs"
|
||||
const sec = { name: "Lateral (Steering)", params: [
|
||||
{ key: "AlwaysOnLateral", settings_tier: "simple", data_type: "bool" },
|
||||
{ key: "VASMEnabled", settings_tier: "advanced", data_type: "bool" },
|
||||
]}
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error("FAIL: " + msg) }
|
||||
assert(P.isSettingVisible(sec, sec.params[0], {}) === true, "simple visible (off)")
|
||||
assert(P.isSettingVisible(sec, sec.params[1], {}) === false, "advanced hidden (off)")
|
||||
assert(P.isSettingVisible(sec, sec.params[1], { GalaxyDeveloperMode: true }) === true, "advanced visible (on)")
|
||||
assert(P.countAdvancedHiddenByDeveloperMode([sec], {}) === 1, "count hidden (off)")
|
||||
assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true }) === 0, "count hidden (on)")
|
||||
const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 }
|
||||
assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap")
|
||||
assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format")
|
||||
console.log("params.js logic OK")
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run([node, str(script)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(_node_exe() is None, reason="no node.js runtime available")
|
||||
def test_ui_hierarchical_tree_logic_runs_and_passes(tmp_path):
|
||||
node = _node_exe()
|
||||
params_src = _read("js/params.js")
|
||||
(tmp_path / "params.mjs").write_text(params_src, encoding="utf-8")
|
||||
|
||||
script = tmp_path / "treecheck.mjs"
|
||||
script.write_text(
|
||||
"""
|
||||
import * as P from "./params.mjs"
|
||||
const params = [
|
||||
{ key: "Parent", ui_type: "toggle" },
|
||||
{ key: "Child", parent_key: "Parent", ui_type: "toggle" },
|
||||
{ key: "GrandChild", parent_key: "Child", ui_type: "toggle" },
|
||||
{ key: "Sibling", ui_type: "toggle" },
|
||||
]
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error("FAIL: " + msg) }
|
||||
|
||||
// Parent off -> no children rendered.
|
||||
let tree = P.buildRenderTree(params, {}, {})
|
||||
assert(tree.length === 2, "parent off shows only roots, got " + tree.length)
|
||||
assert(tree.map(t => t.param.key).join(",") === "Parent,Sibling", "parent off keys")
|
||||
|
||||
// Parent on but collapsed -> still no children.
|
||||
tree = P.buildRenderTree(params, { Parent: true }, {})
|
||||
assert(tree.length === 2, "parent on but collapsed hides children")
|
||||
|
||||
// Parent on + expanded -> children, but grandchild hidden until Child expanded.
|
||||
tree = P.buildRenderTree(params, { Parent: true }, { Parent: true })
|
||||
assert(tree.map(t => t.param.key).join(",") === "Parent,Child,Sibling", "one level deep")
|
||||
assert(tree[1].depth === 1, "child depth is 1")
|
||||
|
||||
// Full expansion -> grandchild at depth 2.
|
||||
tree = P.buildRenderTree(params, { Parent: true, Child: true }, { Parent: true, Child: true })
|
||||
const gc = tree.find(t => t.param.key === "GrandChild")
|
||||
assert(gc && gc.depth === 2, "grandchild nested at depth 2")
|
||||
assert(tree[0].depth === 0, "root depth is 0")
|
||||
|
||||
assert(P.isParamEnabledForChildren({ key: "X" }, { X: true }) === true, "enabled when true")
|
||||
assert(P.isParamEnabledForChildren({ key: "X" }, { X: false }) === false, "disabled when false")
|
||||
assert(P.isParamEnabledForChildren({ ui_type: "group" }, {}) === true, "group always enabled")
|
||||
assert(P.hasChildParams(params, "Parent") === true, "hasChildParams true")
|
||||
assert(P.hasChildParams(params, "Sibling") === false, "hasChildParams false")
|
||||
console.log("hierarchy logic OK")
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run([node, str(script)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
|
||||
@@ -4958,6 +4958,18 @@ def setup(app):
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
def _no_store_response(response):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
def _serve_new_ui():
|
||||
ui_index_path = Path(app.static_folder) / "mobile" / "index.html"
|
||||
if not ui_index_path.is_file():
|
||||
return "Galaxy UI not found", 404
|
||||
return _no_store_response(make_response(send_file(str(ui_index_path))))
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def index():
|
||||
response = make_response(render_template("index.html"))
|
||||
@@ -4966,6 +4978,18 @@ def setup(app):
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
@app.route("/classic", methods=["GET"])
|
||||
@app.route("/classic/", methods=["GET"])
|
||||
def classic_index():
|
||||
return _no_store_response(make_response(render_template("index.html")))
|
||||
|
||||
@app.route("/mobile", methods=["GET"])
|
||||
@app.route("/mobile/", methods=["GET"])
|
||||
@app.route("/ui", methods=["GET"])
|
||||
@app.route("/ui/", methods=["GET"])
|
||||
def mobile_index():
|
||||
return _serve_new_ui()
|
||||
|
||||
@app.route("/api/bluetooth/status", methods=["GET"])
|
||||
def bluetooth_status():
|
||||
try:
|
||||
@@ -7213,6 +7237,15 @@ def setup(app):
|
||||
with _STATS_RESPONSE_LOCK:
|
||||
return _get_stats_locked()
|
||||
|
||||
@app.route("/api/device/status", methods=["GET"])
|
||||
def device_status():
|
||||
return jsonify({
|
||||
"status": "Driving" if params.get_bool("IsOnroad") else "Parked",
|
||||
"online": True,
|
||||
"lanIp": utilities.get_current_lan_ip(),
|
||||
"networkName": utilities.get_current_network_name(),
|
||||
}), 200
|
||||
|
||||
@app.route("/api/stats/ignore_drive", methods=["POST"])
|
||||
def ignore_drive_stats():
|
||||
request_data = request.get_json() or {}
|
||||
|
||||
@@ -1103,7 +1103,7 @@ def _dashboard_time_is_valid(value, now=None, require_recent=False):
|
||||
return True
|
||||
|
||||
|
||||
def _timestamp_to_dashboard_time(timestamp, require_recent=False):
|
||||
def _timestamp_to_dashboard_time(timestamp, require_recent=False, now=None):
|
||||
timestamp = _safe_float(timestamp, 0.0)
|
||||
if timestamp <= 0.0:
|
||||
return None
|
||||
@@ -1111,7 +1111,7 @@ def _timestamp_to_dashboard_time(timestamp, require_recent=False):
|
||||
parsed = datetime.fromtimestamp(timestamp)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
return parsed if _dashboard_time_is_valid(parsed, require_recent=require_recent) else None
|
||||
return parsed if _dashboard_time_is_valid(parsed, require_recent=require_recent, now=now) else None
|
||||
|
||||
|
||||
def _parse_segment_dir_name(name):
|
||||
@@ -1605,15 +1605,15 @@ def _public_drive(drive, is_metric):
|
||||
return public
|
||||
|
||||
|
||||
def _route_time_range(route_info, duration_seconds):
|
||||
def _route_time_range(route_info, duration_seconds, now=None):
|
||||
modified_at = _safe_float(route_info.get("modifiedAt", 0.0), 0.0)
|
||||
duration_seconds = max(0.0, _safe_float(duration_seconds, 0.0))
|
||||
started_at = route_info.get("startedAt")
|
||||
if _dashboard_time_is_valid(started_at, require_recent=True):
|
||||
if _dashboard_time_is_valid(started_at, now=now, require_recent=True):
|
||||
end_time = started_at + timedelta(seconds=duration_seconds) if duration_seconds > 0.0 else None
|
||||
return _jsonable_time(started_at), _jsonable_time(end_time)
|
||||
|
||||
modified_time = _timestamp_to_dashboard_time(modified_at, require_recent=True)
|
||||
modified_time = _timestamp_to_dashboard_time(modified_at, now=now, require_recent=True)
|
||||
if modified_time is not None and duration_seconds > 0.0:
|
||||
end_time = modified_time
|
||||
start_time = end_time - timedelta(seconds=duration_seconds)
|
||||
@@ -1625,10 +1625,10 @@ def _distance_from_meters(distance_m, is_metric):
|
||||
return distance_m * (METER_TO_KILOMETER if is_metric else METER_TO_MILE)
|
||||
|
||||
|
||||
def _route_shell_drive(route_info, params_obj, model_names, is_metric):
|
||||
def _route_shell_drive(route_info, params_obj, model_names, is_metric, now=None):
|
||||
segment_count = max(0, _safe_int(route_info.get("segmentCount", 0), 0))
|
||||
duration_seconds = segment_count * 60
|
||||
start_date, end_date = _route_time_range(route_info, duration_seconds)
|
||||
start_date, end_date = _route_time_range(route_info, duration_seconds, now=now)
|
||||
return {
|
||||
"name": route_info.get("name", ""),
|
||||
"routeNames": [route_info.get("name", "")],
|
||||
@@ -2986,7 +2986,7 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None):
|
||||
|
||||
persistent_stats = _load_dashboard_persistent_stats(params_obj)
|
||||
shell_drives = [
|
||||
_route_shell_drive(route_info, params_obj, model_names, is_metric)
|
||||
_route_shell_drive(route_info, params_obj, model_names, is_metric, now=now)
|
||||
for route_info in route_infos
|
||||
]
|
||||
if shell_drives:
|
||||
|
||||
Reference in New Issue
Block a user