Compare commits

...

1 Commits

Author SHA1 Message Date
Prabhaav Pillai f55ad9162d More Vue native windows. Reduce Duplicate code within API. 2026-09-04 15:52:40 -04:00
45 changed files with 7466 additions and 635 deletions
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Live-reload dev launcher for The Galaxy.
# Usage:
# scripts/galaxy_live.sh # serve repo live on :8083
# scripts/galaxy_live.sh 8099 # or a specific port
# scripts/galaxy_live.sh --sync # sync host runtime first (after big pulls)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PLATFORM="$(uname -s | tr '[:upper:]' '[:lower:]')"
WT="${ROOT}/.host_runtime/${PLATFORM}/worktree"
GX="starpilot/system/the_galaxy"
PORT="${SP_GALAXY_PORT:-8083}"
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: scripts/galaxy_live.sh [--sync] [port]"
exit 0
fi
if [[ "${1:-}" == "--sync" ]]; then
shift
"${ROOT}/scripts/host_tool_runner.sh" sync
fi
if [[ -n "${1:-}" && "${1}" =~ ^[0-9]+$ ]]; then
PORT="${1}"
fi
if [[ ! -d "${WT}/.venv" || ! -f "${WT}/${GX}/the_galaxy.py" ]]; then
echo "Host runtime not ready yet. Run once: ${ROOT}/dev galaxy (then stop it)"
exit 1
fi
rm -rf "${WT}/${GX}"
ln -s "${ROOT}/${GX}" "${WT}/${GX}"
echo "Galaxy live-dev -> http://127.0.0.1:${PORT}/ (backend auto-reload ON)"
echo "Edit repo files. Backend .py restarts; frontend needs a hard-refresh. Errors print here."
echo "Stop with Ctrl+C."
cd "${WT}"
export PYTHONPATH="${WT}:${WT}/starpilot/third_party"
for d in "${WT}"/*_repo; do
[[ -d "${d}" ]] && export PYTHONPATH="${PYTHONPATH}:${d}"
done
export SP_GALAXY_DIR="${SP_GALAXY_DIR:-${HOME}/.comma/starpilot/data/galaxy}"
export SP_GALAXY_HOST="0.0.0.0"
export SP_GALAXY_PORT="${PORT}"
export SP_GALAXY_DEBUG="1"
export SP_GALAXY_RELOAD="1"
exec "${WT}/.venv/bin/python3" -m openpilot.starpilot.system.the_galaxy.the_galaxy
@@ -0,0 +1,443 @@
.dh-view {
--dh-track: rgba(157, 114, 255, 0.18);
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin: 0 auto;
max-width: 1060px;
width: 100%;
}
.dh-card { margin-bottom: 0; }
.dh-hero {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--sp-3) var(--sp-4);
justify-content: space-between;
padding: var(--sp-1) 0;
}
.dh-hero__info { min-width: 0; }
.dh-title {
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0;
}
.dh-sub {
align-items: center;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
font-size: var(--fs-sm);
gap: var(--sp-2);
margin: var(--sp-2) 0 0;
}
.dh-refresh { flex: 0 0 auto; }
.dh-card__head {
align-items: center;
background: rgba(255, 255, 255, 0.02);
display: flex;
gap: var(--sp-3);
min-height: 52px;
padding: var(--sp-3) var(--sp-4);
}
.dh-card__head i {
color: var(--primary);
font-size: 1.25rem;
}
.dh-card__head span {
font-weight: var(--fw-bold);
letter-spacing: -0.01em;
}
.dh-body { padding: var(--sp-4); }
.dh-label {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
letter-spacing: 0.08em;
margin: var(--sp-2) 0 0;
text-transform: uppercase;
}
.dh-grid {
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.dh-grid--2 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
/* ---- Metric grids (last drive / summary / week) ---- */
.dh-metrics {
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.dh-metrics--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dh-metric { min-width: 0; }
.dh-metric strong {
color: var(--on-surface);
display: block;
font-size: var(--fs-lg);
line-height: 1.1;
overflow-wrap: anywhere;
}
.dh-metric span {
color: var(--text-muted);
display: block;
font-size: var(--fs-xs);
margin-top: 2px;
}
.dh-stat .dh-body { padding-top: var(--sp-3); }
.dh-date {
color: var(--text-muted);
font-size: var(--fs-sm);
margin-bottom: var(--sp-3);
}
.dh-tags {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
margin-top: var(--sp-4);
min-width: 0;
}
.dh-tag {
align-items: center;
color: var(--text-muted);
display: inline-flex;
font-size: var(--fs-sm);
gap: var(--sp-2);
min-width: 0;
overflow-wrap: anywhere;
}
.dh-tag i { color: var(--primary); }
/* ---- Donut ring (engagement) ---- */
.dh-donut {
--dh-value: 0;
display: grid;
flex: 0 0 auto;
height: 116px;
place-items: center;
position: relative;
width: 116px;
}
.dh-donut::before {
background: conic-gradient(var(--primary) calc(var(--dh-value) * 1%), var(--dh-track) 0);
border-radius: 50%;
content: "";
inset: 0;
position: absolute;
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px));
mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px));
}
.dh-donut strong,
.dh-donut span {
position: relative;
}
.dh-donut strong {
font-size: var(--fs-lg);
line-height: 1;
}
.dh-donut span {
color: var(--text-muted);
font-size: var(--fs-xs);
}
.dh-week-top {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.dh-week-top .dh-metrics { flex: 1 1 180px; }
/* ---- Distance-per-day bar chart ---- */
.dh-bars {
align-items: end;
display: grid;
gap: var(--sp-2);
grid-template-columns: repeat(7, minmax(0, 1fr));
height: 120px;
}
.dh-bar-day {
display: grid;
grid-template-rows: 1fr auto;
gap: var(--sp-2);
height: 100%;
justify-items: center;
min-width: 0;
}
.dh-bar-day__bar {
align-self: end;
background: linear-gradient(180deg, var(--primary), var(--secondary));
border-radius: 4px 4px 0 0;
min-height: 4px;
width: min(100%, 22px);
}
.dh-bar-day > span {
color: var(--text-muted);
font-size: var(--fs-xs);
}
/* ---- Ring chart (favorite models share) ---- */
.dh-chart-ring {
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(100% - 21px));
mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(100% - 21px));
aspect-ratio: 1;
border-radius: 50%;
flex: 0 0 auto;
height: 132px;
width: 132px;
}
.dh-models {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
}
.dh-models__list {
display: grid;
flex: 1 1 160px;
gap: var(--sp-3);
min-width: 0;
}
.dh-model {
align-items: center;
display: grid;
gap: var(--sp-2) var(--sp-3);
grid-template-columns: auto minmax(0, 1fr);
min-width: 0;
}
.dh-swatch {
border-radius: 50%;
display: inline-block;
height: 12px;
width: 12px;
}
.dh-model__body { min-width: 0; }
.dh-model__body strong,
.dh-model__body small {
display: block;
overflow-wrap: anywhere;
}
.dh-model__body small {
color: var(--text-muted);
font-size: var(--fs-xs);
margin-top: 2px;
}
/* ---- Records / key-value lists ---- */
.dh-list .gx-row__value {
margin-left: auto;
max-width: 60%;
overflow-wrap: anywhere;
text-align: right;
white-space: normal;
}
.dh-row--stack {
align-items: stretch;
flex-direction: column;
gap: var(--sp-3);
}
.dh-record {
align-items: center;
border-top: 1px solid var(--outline);
display: grid;
gap: var(--sp-3);
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 60px;
padding: var(--sp-3) var(--sp-4);
}
.dh-record:first-child { border-top: 0; }
.dh-record__icon {
align-items: center;
background: var(--primary-container);
border-radius: var(--radius-sm);
color: var(--primary);
display: inline-flex;
height: 2.2rem;
justify-content: center;
width: 2.2rem;
}
.dh-record__body {
display: flex;
flex-direction: column;
min-width: 0;
}
.dh-record__name {
color: var(--text-muted);
font-size: var(--fs-xs);
}
.dh-record__value {
font-size: var(--fs-base);
overflow-wrap: anywhere;
}
.dh-record__detail {
color: var(--text-muted);
font-size: var(--fs-xs);
max-width: 180px;
overflow-wrap: anywhere;
text-align: right;
}
/* ---- Recent drive rows ---- */
.dh-drive {
border-top: 1px solid var(--outline);
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
}
.dh-drive:first-child { border-top: 0; }
.dh-drive.is-ignored { opacity: 0.72; }
.dh-drive.is-pending { color: var(--text-muted); }
.dh-drive__main strong,
.dh-drive__main span {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dh-drive__main strong { font-size: var(--fs-base); }
.dh-drive__main span {
color: var(--text-muted);
font-size: var(--fs-sm);
margin-top: 2px;
}
.dh-drive__meta {
display: flex;
flex-wrap: wrap;
font-size: var(--fs-sm);
gap: var(--sp-2) var(--sp-4);
}
.dh-drive__row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--sp-3) var(--sp-4);
}
.dh-drive__cell { flex: 1 1 180px; min-width: 0; }
.dh-drive__engaged {
display: block;
font-size: var(--fs-sm);
margin-top: var(--sp-2);
}
.dh-drive__muted { color: var(--text-muted); }
.dh-drive__attention {
display: flex;
flex-wrap: wrap;
font-size: var(--fs-sm);
gap: var(--sp-2) var(--sp-4);
}
.dh-drive__attention span {
align-items: center;
display: inline-flex;
gap: var(--sp-2);
}
.dh-drive__attention i { color: var(--primary); }
.dh-drive__action { justify-self: start; }
.dh-drive__action:disabled { opacity: 0.5; }
/* ---- Progress tracks ---- */
.dh-track {
background: var(--dh-track);
border-radius: var(--radius-full);
height: 8px;
overflow: hidden;
width: 100%;
}
.dh-track--lg { height: 12px; }
.dh-track > span {
background: linear-gradient(90deg, var(--primary), var(--secondary));
border-radius: inherit;
display: block;
height: 100%;
}
.dh-muted {
color: var(--text-muted);
font-size: var(--fs-sm);
margin: 0 0 var(--sp-3);
}
.dh-software a {
color: var(--primary);
text-decoration-color: var(--primary-glow);
text-underline-offset: 0.16em;
}
@media (max-width: 520px) {
.dh-metrics,
.dh-metrics--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dh-week-top,
.dh-models { align-items: center; justify-content: center; }
.dh-record__detail { max-width: 120px; }
}
@@ -899,12 +899,81 @@ input[type="color"].gx-color {
.gx-btn--tonal:hover { background: rgba(157, 114, 255, 0.28); }
.gx-btn--block { width: 100%; }
.gx-btn--danger { background: var(--error); color: var(--on-error); box-shadow: 0 4px 12px rgba(255, 82, 119, 0.3); }
.gx-btn--danger:hover { background: #ff6b8b; }
[data-theme="light"] .gx-btn--danger:hover { background: #e04b6f; }
.gx-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: var(--sp-4);
}
.gx-tab {
align-items: center;
background: var(--surface-container);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
color: var(--on-surface-variant);
cursor: pointer;
display: inline-flex;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
gap: 6px;
justify-content: center;
min-height: 40px;
padding: 0 var(--sp-4);
transition: background-color var(--motion-fast), color var(--motion-fast), border-color var(--motion-fast);
}
.gx-tab:hover { background: var(--glass-bg-hover); color: var(--on-surface); }
.gx-tab:active { transform: scale(0.97); }
[data-theme="light"] .gx-tab { border: 1.5px solid rgba(120, 73, 232, 0.3); }
.gx-tab.active {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 4px 12px var(--primary-glow);
color: var(--on-primary);
font-weight: var(--fw-bold);
}
.gx-note {
color: var(--text-muted);
font-size: var(--fs-sm);
line-height: 1.5;
margin: var(--sp-2) 0;
}
.gx-note strong { color: var(--on-surface); font-weight: var(--fw-bold); }
.gx-note--danger { color: var(--error); }
.gx-note--danger strong { color: var(--error); }
.gx-grid {
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.gx-card-grid {
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.gx-card-grid .gx-card { margin-bottom: 0; }
.gx-tight-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.gx-tight-grid .gx-row { border-radius: var(--radius-md); }
.gx-tile {
align-items: center;
background: var(--surface-container);
@@ -961,6 +1030,23 @@ input[type="color"].gx-color {
color: var(--error);
}
.gx-chip-x {
align-items: center;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
display: inline-flex;
line-height: 1;
margin: 0;
padding: 0 0 0 2px;
}
.gx-chip-x:disabled {
cursor: default;
opacity: 0.4;
}
.gx-alert {
align-items: center;
backdrop-filter: var(--glass-blur);
@@ -985,6 +1071,14 @@ input[type="color"].gx-color {
border-left-color: var(--warning);
}
.gx-alert--danger {
background: rgba(255, 82, 119, 0.12);
border-left-color: var(--error);
}
.gx-alert--danger .gx-alert__icon { color: var(--error); }
.gx-alert--danger .gx-alert__body span { color: var(--error); }
.gx-alert__icon { font-size: 1.4rem; color: var(--primary); }
.gx-alert__body { flex: 1 1 220px; }
.gx-alert__body strong { display: block; font-weight: var(--fw-bold); }
@@ -1131,6 +1225,20 @@ input[type="color"].gx-color {
margin-top: var(--sp-5);
}
/* Native-style bottom sheet used for fullscreen player overlays. Kept as a
sibling of gx-sheet so confirm dialogs keep their centered look. */
.gx-scrim--bottomsheet {
align-items: flex-end;
padding: var(--sp-4) 0 calc(var(--bottomnav-height) + var(--sp-3));
}
.gx-scrim--bottomsheet .gx-sheet {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
max-height: min(86dvh, 760px);
max-width: 720px;
}
.gx-tree-node { position: relative; }
.gx-tree-node--child {
@@ -1293,6 +1401,84 @@ input[type="color"].gx-color {
}
.gx-collapse-enter-from, .gx-collapse-leave-to { max-height: 0; opacity: 0; }
/* ---- PWA install guidance (Galaxy page) ---- */
.gx-install__body {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
}
.gx-install__lead {
color: var(--on-surface-variant);
line-height: 1.55;
margin: 0;
}
.gx-install__lead strong { color: var(--on-surface); }
.gx-install__benefits {
display: grid;
gap: 10px;
list-style: none;
margin: 0;
padding: 0;
}
.gx-install__benefits li {
align-items: center;
color: var(--on-surface-variant);
display: flex;
font-size: var(--fs-sm);
gap: var(--sp-3);
line-height: 1.4;
}
.gx-install__benefits i {
color: var(--primary);
flex: 0 0 auto;
font-size: 1.15rem;
text-align: center;
width: 22px;
}
.gx-install__hint {
align-items: flex-start;
background: var(--surface-container);
border-radius: var(--radius-sm);
color: var(--text-muted);
display: flex;
font-size: var(--fs-sm);
gap: var(--sp-2);
line-height: 1.5;
margin: 0;
padding: var(--sp-3);
}
.gx-install__hint i {
color: var(--primary);
flex: 0 0 auto;
font-size: 1rem;
margin-top: 1px;
}
.gx-install__steps {
color: var(--text-muted);
display: grid;
font-size: var(--fs-sm);
gap: var(--sp-2);
line-height: 1.5;
margin: var(--sp-2) 0 0;
padding-left: var(--sp-5);
}
.gx-install__actions {
border-top: 1px solid var(--outline);
padding-top: var(--sp-3);
}
.gx-install__manual { display: grid; gap: var(--sp-2); }
@media (max-width: 767px) {
.liquid-glass-nav { display: flex; }
.gx-menu-btn { display: none; }
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, 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">
@@ -16,6 +16,7 @@
<link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/mobile/css/material.css">
<link rel="stylesheet" href="/assets/mobile/css/home.css">
<script type="importmap">
{
@@ -1,6 +1,6 @@
export const LAYOUT_URL = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1"
async function handle(res) {
async function parse(res) {
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data?.error || data?.message || res.statusText || "Request failed")
@@ -10,85 +10,65 @@ async function handle(res) {
return data
}
export const api = {
async postAction(endpoint) {
const res = await fetch(endpoint, { method: "POST" })
return handle(res)
},
function initFor({ method = "GET", data, form, headers, cache, signal } = {}) {
const init = { method }
if (cache) init.cache = cache
if (signal) init.signal = signal
if (data !== undefined) {
init.headers = { "Content-Type": "application/json", ...(headers || {}) }
init.body = JSON.stringify(data)
} else if (form !== undefined) {
if (headers) init.headers = headers
init.body = form
} else if (headers) init.headers = headers
return init
}
async getOptions(endpoint) {
const res = await fetch(endpoint)
return handle(res)
},
function request(url, opts) {
return fetch(url, initFor(opts)).then(parse)
}
async function requestOk(url, opts) {
const res = await fetch(url, initFor(opts))
return res.ok ? parse(res) : null
}
async function delOk(url) {
return (await fetch(url, { method: "DELETE" })).ok
}
export const api = {
postAction(endpoint) { return request(endpoint, { method: "POST" }) },
getOptions(endpoint) { return request(endpoint) },
async getLayout() {
const res = await fetch(LAYOUT_URL, { cache: "no-store" })
const data = await handle(res)
const data = await request(LAYOUT_URL, { cache: "no-store" })
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)
},
getParams() { return request("/api/params/all") },
async getDefaults() {
const res = await fetch("/api/params/defaults")
return res.ok ? handle(res) : {}
return res.ok ? parse(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)
updateParam({ key, value, label }) {
const data = { key, value }
if (label) data.label = label
return request("/api/params", { method: "PUT", data })
},
async getFlmWorkspace() {
const res = await fetch("/api/flm/workspace", { cache: "no-store" })
return res.ok ? handle(res) : null
},
getFlmWorkspace() { return requestOk("/api/flm/workspace", { cache: "no-store" }) },
getFavoritesSlots() { return request("/api/favorites/slots", { cache: "no-store" }) },
saveFavoritesSlots(slots) { return request("/api/favorites/slots", { method: "PUT", data: { slots } }) },
activateFavoriteAction(key) { return request("/api/favorites/action", { method: "POST", data: { key } }) },
async getFavoritesSlots() {
const res = await fetch("/api/favorites/slots", { cache: "no-store" })
return handle(res)
},
getDeviceStatus() { return requestOk("/api/device/status") },
getStats() { return requestOk("/api/stats") },
setDriveStats(action, routeNames) { return request(`/api/stats/${action}_drive`, { method: "POST", data: { routeNames } }) },
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})`)
@@ -113,253 +93,66 @@ export const api = {
}
},
async getRoute(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`)
return handle(res)
},
getRoute(name) { return request(`/api/routes/${encodeURIComponent(name)}`) },
deleteRoute(name) { return request(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" }) },
renameRoute(oldName, newName) { return request("/api/routes/rename", { method: "POST", data: { old: oldName, new: newName } }) },
resetRouteName(name) { return request("/api/routes/reset_name", { method: "POST", data: { name } }) },
setRoutePreserved(name, preserved) { return request(`/api/routes/${encodeURIComponent(name)}/preserve`, { method: preserved ? "POST" : "DELETE" }) },
deleteAllRoutes(includePreserved) { return request(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" }) },
getRouteLogs(name) { return request(`/api/routes/${encodeURIComponent(name)}/logs`) },
async deleteRoute(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" })
return handle(res)
},
getScreenRecordings() { return request("/api/screen_recordings/list") },
deleteScreenRecording(filename) { return request(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" }) },
deleteAllScreenRecordings() { return request("/api/screen_recordings/delete_all", { method: "DELETE" }) },
renameScreenRecording(oldName, newName) { return request("/api/screen_recordings/rename", { method: "POST", data: { old: oldName, new: newName } }) },
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
},
getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) },
getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) },
deleteErrorLog(filename) { return delOk(`/api/error_logs/${encodeURIComponent(filename)}`) },
deleteAllErrorLogs() { return delOk("/api/error_logs/delete_all") },
getTmuxLogs() { return request("/api/tmux_log/list") },
tmuxCapture() { return postOk("/api/tmux_log/capture") },
tmuxSnapshot() { return request("/api/tmux_log/snapshot") },
deleteTmuxLog(filename) { return delOk(`/api/tmux_log/delete/${encodeURIComponent(filename)}`) },
deleteAllTmuxLogs() { return delOk("/api/tmux_log/delete_all") },
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)
},
runTroubleshoot() { return request("/api/troubleshoot", { method: "POST" }) },
getTroubleshoot() { return requestOk("/api/troubleshoot") },
resetTroubleshoot() { return postOk("/api/troubleshoot/reset") },
resetTroubleshootSection(sectionId) { return request("/api/troubleshoot/reset", { method: "POST", data: { sectionId } }) },
async getTroubleshoot() {
const res = await fetch("/api/troubleshoot")
return res.ok ? handle(res) : null
},
getWheelControlsStatus() { return request("/api/wheel-controls/status", { cache: "no-store" }) },
wheelControlsOp(operation, body = {}) { return request(`/api/wheel-controls/${operation}`, { method: "POST", data: body }) },
async resetTroubleshoot() {
const res = await fetch("/api/troubleshoot/reset", { method: "POST" })
return res.ok
},
getBluetoothStatus() { return request("/api/bluetooth/status") },
bluetoothOp(operation, body = {}) { return request(`/api/bluetooth/${operation}`, { method: "POST", data: body }) },
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 = "") {
carFeaturesCheck(tool = "") {
const query = tool ? `?tool=${encodeURIComponent(tool)}` : ""
const res = await fetch(`/api/car_features_check${query}`)
return res.ok ? handle(res) : null
return requestOk(`/api/car_features_check${query}`)
},
async lateralManeuvers(action) {
const res = await fetch(`/api/lateral_maneuvers/${action}`, { method: "POST" })
return handle(res)
},
lateralManeuvers(action) { return request(`/api/lateral_maneuvers/${action}`, { method: "POST" }) },
lateralManeuversStatus() { return request("/api/lateral_maneuvers/status") },
longitudinalManeuvers(action) { return request(`/api/longitudinal_maneuvers/${action}`, { method: "POST" }) },
longitudinalManeuversStatus() { return request("/api/longitudinal_maneuvers/status") },
async lateralManeuversStatus() {
const res = await fetch("/api/lateral_maneuvers/status")
return handle(res)
},
getMapsStatus() { return request("/api/maps/status") },
getMapsCatalog() { return request("/api/maps/catalog") },
mapsOp(operation, body = {}) { return request(`/api/maps/${operation}`, { method: "POST", data: body }) },
async longitudinalManeuvers(action) {
const res = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
return handle(res)
},
getNavigation() { return request("/api/navigation") },
setNavigation(body) { return request("/api/navigation", { method: "POST", data: body }) },
getNavigationKeys() { return request("/api/navigation_key") },
setNavigationKey(body) { return request("/api/navigation_key", { method: "POST", data: body }) },
navigationFavorite(body) { return request("/api/navigation/favorite", { method: "POST", data: body }) },
deleteNavigationKey(type) { return request(`/api/navigation_key?type=${encodeURIComponent(type)}`, { method: "DELETE" }) },
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) {
@@ -368,107 +161,155 @@ export const api = {
}
return res.blob()
},
restoreToggles(data) { return request("/api/toggles/restore", { method: "POST", data }) },
resetTogglesDefault() { return request("/api/toggles/reset_default", { method: "POST" }) },
async restoreToggles(data) {
const res = await fetch("/api/toggles/restore", {
getUpdateBranches() { return request("/api/update/branches") },
getUpdateBranch() { return request("/api/update/branch") },
setUpdateBranch(branch) { return request("/api/update/branch", { method: "POST", data: { branch } }) },
updateFast() { return request("/api/update/fast", { method: "POST" }) },
getUpdateFastStatus() { return request("/api/update/fast/status") },
updateRecover() { return request("/api/update/recover", { method: "POST" }) },
updateRollback() { return request("/api/update/rollback", { method: "POST" }) },
factoryReset() { return request("/api/update/factory_reset", { method: "POST" }) },
getAgnosStatus() { return requestOk("/api/update/agnos_status") },
getVasmConfig() { return request("/api/v_asm/config") },
setVasmConfig(body) { return request("/api/v_asm/config", { method: "POST", data: body }) },
vasmSnapshot() { return requestOk("/api/v_asm/snapshot", { cache: "no-store" }) },
getPipConfig() { return request("/api/pip_preview/config") },
setPipConfig(body) { return request("/api/pip_preview/config", { method: "POST", data: body }) },
pipSnapshot() { return requestOk("/api/pip_preview/snapshot", { cache: "no-store" }) },
getGalaxyStatus() { return requestOk("/api/galaxy/status") },
getSpeedLimitsStatus() { return request("/api/speed_limits/status") },
processSpeedLimits() { return request("/api/speed_limits/process", { method: "POST" }) },
getTskKeys() { return request("/api/tsk_keys") },
saveTskKeys(keys) { return request("/api/tsk_keys", { method: "POST", data: keys }) },
deleteTskKey(name) { return request(`/api/tsk_keys?name=${encodeURIComponent(name)}`, { method: "DELETE" }) },
tskKeySet(name, value) { return request("/api/tsk_key_set", { method: "POST", data: { name, value } }) },
async galaxyPair(password) {
const res = await fetch("/api/galaxy/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
body: JSON.stringify({ password }),
})
return handle(res)
const data = await parse(res).catch(() => ({}))
if (!res.ok) throw new Error(data?.error || data?.message || "Pairing failed.")
return data
},
async resetTogglesDefault() {
const res = await fetch("/api/toggles/reset_default", { method: "POST" })
return handle(res)
async galaxyUnpair() {
const res = await fetch("/api/galaxy/unpair", { method: "POST" })
const data = await parse(res).catch(() => ({}))
if (!res.ok) throw new Error(data?.error || data?.message || "Unpairing failed.")
return data
},
async getUpdateBranches() {
const res = await fetch("/api/update/branches")
return handle(res)
selectTestingGround(body) { return request("/api/testing_grounds/select", { method: "POST", data: body }) },
getSentryStatus() { return requestOk("/api/sentry/status", { cache: "no-store" }) },
getSentryEvents() { return request("/api/sentry/events", { cache: "no-store" }) },
getSentryLive() { return request("/api/sentry/live", { cache: "no-store" }) },
deleteSentryEvent(eventId) { return request(`/api/sentry/events/${encodeURIComponent(eventId)}`, { method: "DELETE" }) },
async getSentryPushConfig() {
const res = await fetch("/api/sentry/push/config", { cache: "no-store" })
const data = await parse(res).catch(() => ({}))
if (!res.ok) return { enabled: false, error: data?.error || "Galaxy Web Push is unavailable." }
return data
},
sentryPushSubscribe(body) { return request("/api/sentry/push/subscribe", { method: "POST", data: body }) },
getModelStatus() { return requestOk("/api/models/status", { cache: "no-store" }) },
startModelDownload(modelKey, allowGpuWithoutGpu = false) { return request("/api/models/download", { method: "POST", data: { model: modelKey, allowGpuWithoutGpu } }) },
downloadAllModels(allowGpuWithoutGpu = false) { return request("/api/models/download_all", { method: "POST", data: { allowGpuWithoutGpu } }) },
deleteModel(modelKey) { return request("/api/models/delete", { method: "POST", data: { model: modelKey } }) },
saveModelPreferences(prefs = {}) { return request("/api/models/preferences", { method: "PUT", data: prefs }) },
getPlotsLive() { return request("/api/plots/live") },
getGalaxySession() { return request("/api/galaxy/session") },
getThemeList() { return request("/api/themes/list") },
getThemeDefault() { return request("/api/themes/default") },
loadTheme(path, type) {
const qs = type ? `?type=${encodeURIComponent(type)}` : ""
return request(`/api/themes/load/${encodeURIComponent(path)}${qs}`)
},
saveTheme(formData) { return request("/api/themes", { method: "POST", form: formData }) },
applyTheme(formData) { return request("/api/themes/apply", { method: "POST", form: formData }) },
deleteTheme(path, type) {
const qs = type ? `?type=${encodeURIComponent(type)}` : "?type=user"
return request(`/api/themes/delete/${encodeURIComponent(path)}${qs}`, { method: "DELETE" })
},
async downloadTheme(formData) {
const res = await fetch("/api/themes/download", { method: "POST", body: formData })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.message || data?.error || "Failed to export theme.")
}
return res.blob()
},
async getThemeAssetBlob(path, type, assetPath) {
const encodedAsset = String(assetPath || "").split("/").map((seg) => encodeURIComponent(seg)).join("/")
const qs = type ? `?type=${encodeURIComponent(type)}` : ""
const res = await fetch(`/api/themes/asset/${encodeURIComponent(path)}/${encodedAsset}${qs}`)
if (!res.ok) throw new Error("Failed to load theme asset.")
return res.blob()
},
async getUpdateBranch() {
const res = await fetch("/api/update/branch")
return handle(res)
getFlmStatus() { return requestOk("/api/flm/status", { cache: "no-store" }) },
getFlmReport(reportId) { return requestOk(`/api/flm/report/${encodeURIComponent(reportId)}`, { cache: "no-store" }) },
flmDeleteReport(reportId) { return request(`/api/flm/report/${encodeURIComponent(reportId)}`, { method: "DELETE" }) },
flmSelectPath(reportId, pathKey) { return request(`/api/flm/report/${encodeURIComponent(reportId)}/path`, { method: "POST", data: { pathKey } }) },
flmAnalyze(routes, segmentRanges) { return request("/api/flm/analyze", { method: "POST", data: { routes, segmentRanges: segmentRanges || {} } }) },
flmStopAnalyze() { return request("/api/flm/analyze/stop", { method: "POST" }) },
flmApplyTrial(reportId, profileId) { return request("/api/flm/trials/apply", { method: "POST", data: { reportId, profileId } }) },
flmRevertTrial() { return request("/api/flm/trials/revert", { method: "POST" }) },
flmAcceptTrial() { return request("/api/flm/trials/accept", { method: "POST" }) },
flmSaveFeedback(reportId, feedback) { return request("/api/flm/feedback", { method: "POST", data: { reportId, ...feedback } }) },
flmSaveTune(name) { return request("/api/flm/saved-tunes", { method: "POST", data: { name } }) },
flmApplySavedTune(tuneId) { return request(`/api/flm/saved-tunes/${encodeURIComponent(tuneId)}/apply`, { method: "POST" }) },
flmRenameSavedTune(tuneId, name) { return request(`/api/flm/saved-tunes/${encodeURIComponent(tuneId)}`, { method: "PATCH", data: { name } }) },
flmDeleteSavedTune(tuneId) { return request(`/api/flm/saved-tunes/${encodeURIComponent(tuneId)}`, { method: "DELETE" }) },
flmSubmitTune(tuneId, discordUsername) { return request(`/api/flm/saved-tunes/${encodeURIComponent(tuneId)}/submit`, { method: "POST", data: { discordUsername } }) },
async getVasmSnapshotBlob() {
const res = await fetch("/api/v_asm/snapshot", { cache: "no-store" })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.error || res.statusText || "Failed to load snapshot")
}
return res.blob()
},
async setUpdateBranch(branch) {
const res = await fetch("/api/update/branch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ branch }),
})
return handle(res)
deleteVasmConfig() { return request("/api/v_asm/config", { method: "DELETE" }) },
async getMemoryParam(key) {
const res = await fetch(`/api/params_memory?key=${encodeURIComponent(key)}`, { cache: "no-store" })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.error || res.statusText || "Request failed")
}
return res.text()
},
async updateFast() {
const res = await fetch("/api/update/fast", { method: "POST" })
return handle(res)
},
deletePipConfig() { return request("/api/pip_preview/config", { method: "DELETE" }) },
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() {
async pipSnapshotSource() {
const res = await fetch("/api/pip_preview/snapshot")
return res.ok ? handle(res) : null
if (!res.ok) return parse(res)
const contentType = res.headers.get("content-type") || ""
if (contentType.includes("application/json")) {
const data = await parse(res)
if (!data.jpeg) throw new Error("Snapshot missing image data")
return { src: `data:image/jpeg;base64,${data.jpeg}`, cleanup: null }
}
const src = URL.createObjectURL(await res.blob())
return { src, cleanup: () => URL.revokeObjectURL(src) }
},
}
@@ -10,6 +10,15 @@ 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 { Doors } from "./views/Doors.js"
import { Galaxy } from "./views/Galaxy.js"
import { Tsk } from "./views/Tsk.js"
import { Sentry } from "./views/Sentry.js"
import { ModelManager } from "./views/ModelManager.js"
import { Plots } from "./views/Plots.js"
import { TestingGround } from "./views/TestingGround.js"
import { ThemeMaker } from "./views/ThemeMaker.js"
import { Cameras } from "./views/Cameras.js"
import { store, initRouter, navigate } from "./store.js"
import { showSnackbar } from "./api.js"
@@ -35,6 +44,15 @@ const VIEWS = {
"/vehicle": Vehicle,
"/system": SystemTools,
"/embed": ToolEmbed,
"/manage_doors": Doors,
"/galaxy": Galaxy,
"/manage_tsk": Tsk,
"/sentry": Sentry,
"/manage_models": ModelManager,
"/plots": Plots,
"/testing_ground": TestingGround,
"/theme_maker": ThemeMaker,
"/cameras": Cameras,
}
function resolveView(path) {
@@ -7,25 +7,22 @@ const NAV = {
{ 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: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" },
{ 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: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tuning, Plots & Testing", link: "/tuning", icon: "bi-sign-turn-right" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" },
],
}
const BOTTOM_NAV = [
{ name: "Home", link: "/", icon: "bi-house-fill" },
{ name: "Settings", link: "/settings", icon: "bi-toggle-on" },
{ name: "Toggles", link: "/settings", icon: "bi-toggle-on" },
{ name: "Tools", link: "/tools", icon: "bi-tools" },
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
]
@@ -104,8 +101,8 @@ export const AppShell = {
</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" />
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search toggles..."
v-model="search" aria-label="Search toggles" />
<button v-if="search" type="button" class="gx-search-clear" aria-label="Clear search" @click="clearSearch">
<i class="bi bi-x"></i>
</button>
@@ -1,10 +1,12 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GxNotice } from "./GxNotice.js"
function address(device) { return String(device.address || "").toUpperCase() }
export const BluetoothPanel = {
name: "BluetoothPanel",
components: { GxNotice },
data() {
return {
loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false,
@@ -84,8 +86,8 @@ export const BluetoothPanel = {
<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>
<GxNotice v-if="!offroad" text="Scanning, pairing, and forgetting devices are available offroad only." style="margin:0 0 var(--sp-2);" />
<GxNotice v-if="error" tone="danger" :text="error" style="margin:0 0 var(--sp-2);" />
<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>
@@ -115,7 +117,7 @@ export const BluetoothPanel = {
<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>
<button v-if="d.paired" type="button" class="gx-btn gx-btn--danger" :disabled="offroadDisabled()" @click="forget(d)"><i class="bi bi-trash"></i></button>
</div>
</div>
@@ -1,8 +1,5 @@
// 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.
// Ideally, implement new tools Vue based instead of Arrow.
export const GalaxyEmbed = {
name: "GalaxyEmbed",
props: {
@@ -38,8 +38,16 @@ export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger
const host = document.createElement("div")
document.body.appendChild(host)
const { createApp, h } = window.__galaxyVue
let instance
const app = createApp({
let app
let settled = false
const finish = (value) => {
if (settled) return
settled = true
resolve(value)
try { app?.unmount?.() } catch (e) {}
host.remove()
}
app = createApp({
render() {
return h(GalaxyModal, {
modelValue: true,
@@ -47,17 +55,12 @@ export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger
message,
confirmLabel,
danger,
"onUpdate:modelValue": (v) => { if (!v) teardown() },
onConfirm: () => { teardown(); resolve(true) },
onCancel: () => { teardown(); resolve(false) },
"onUpdate:modelValue": (v) => { if (!v) finish(false) },
onConfirm: () => finish(true),
onCancel: () => finish(false),
})
},
})
const teardown = () => {
app.unmount()
host.remove()
resolve(false)
}
instance = app.mount(host)
app.mount(host)
})
}
@@ -5,18 +5,25 @@ export const GalaxySection = {
icon: { type: String, default: "bi-toggles" },
count: { type: [Number, String], default: "" },
defaultOpen: { type: Boolean, default: true },
collapsible: { type: Boolean, default: true },
},
data() { return { open: this.defaultOpen } },
template: `
<section class="gx-card">
<div class="gx-section__header" role="button" @click="open = !open">
<div class="gx-section__header" v-if="collapsible" role="button" tabindex="0"
@click="open = !open" @keydown.enter="open = !open" @keydown.space.prevent="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>
<div v-else class="gx-section__header">
<i class="bi" :class="icon"></i>
<span class="gx-section__title">{{ title }}</span>
<span v-if="count !== ''" class="gx-section__count">{{ count }}</span>
</div>
<transition name="gx-collapse">
<div v-show="open" class="gx-section__body">
<div v-show="!collapsible || open" class="gx-section__body">
<slot />
</div>
</transition>
@@ -0,0 +1,16 @@
export const GalaxyTabs = {
name: "GalaxyTabs",
props: {
items: { type: Object, required: true },
active: { type: String, default: "" },
},
emits: ["select"],
template: `
<nav class="gx-tabs" role="tablist" aria-label="View sections">
<button v-for="(label, key) in items" :key="key" type="button" role="tab"
:aria-selected="active === key ? 'true' : 'false'"
class="gx-tab" :class="{ active: active === key }"
@click="$emit('select', key)">{{ label }}</button>
</nav>
`,
}
@@ -0,0 +1,24 @@
export const GxNotice = {
name: "GxNotice",
props: {
tone: { type: String, default: "warn" },
icon: { type: String, default: "bi-exclamation-triangle-fill" },
title: { type: String, default: "" },
text: { type: String, default: "" },
},
computed: {
toneClass() {
const t = ["warn", "info", "danger"].includes(this.tone) ? this.tone : "warn"
return "gx-alert--" + t
},
},
template: `
<div class="gx-alert" :class="toneClass" role="status">
<i class="bi gx-alert__icon" :class="icon"></i>
<div class="gx-alert__body">
<strong v-if="title">{{ title }}</strong>
<span><slot>{{ text }}</slot></span>
</div>
</div>
`,
}
@@ -0,0 +1,707 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "./GalaxyModal.js"
import { GxNotice } from "./GxNotice.js"
const MAX_ROUTES = 250
export const LateralTuningPanel = {
name: "LateralTuningPanel",
components: { GxNotice },
data() {
return {
loading: true,
busy: false,
error: "",
loadingRoutes: false,
workspace: { reports: [], savedTunes: [], currentCarFingerprint: "", activeTrial: null, status: {} },
statusData: null,
isOnroad: false,
laneCentering: false,
routes: [],
selectedRoutes: [],
report: null,
reportLoading: false,
loadedReportId: "",
lastStatusReportId: "",
feedbackAccepted: [],
feedbackIgnored: [],
feedbackNotes: "",
pending: null,
pendingName: "",
}
},
created() {
this.poll = usePolling(() => this.refresh(), { interval: 4000 })
this.refresh()
this.loadRoutes()
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
computed: {
status() {
const merged = { ...((this.workspace.status) || {}), ...(this.statusData || {}) }
return merged
},
activeTrial() { return this.workspace.activeTrial || null },
comparisonRows() {
const rep = this.report
if (!rep) return []
const stock = rep.stockParams || {}
const current = rep.currentParams || {}
const trial = (this.activeTrial && this.activeTrial.appliedGenericParams) || {}
const angle = rep.car && rep.car.controlPath === "angle"
const pairs = angle
? [["Auto steer delay", "UseAutoSteerDelay"], ["Steer delay", "SteerDelay"], ["Steer ratio", "SteerRatio"]]
: [
["Lat accel", "SteerLatAccel"], ["Friction", "SteerFriction"], ["Auto steer delay", "UseAutoSteerDelay"],
["Steer delay", "SteerDelay"], ["Steer ratio", "SteerRatio"], ["KP", "SteerKP"],
]
const rows = pairs.map(([label, key]) => ({
key,
label,
curve: false,
stock: stock[key],
current: Object.prototype.hasOwnProperty.call(trial, key) ? trial[key] : current[key],
}))
if (angle) return rows
const merged = this.mergedOverrides
for (const [family, payload] of Object.entries(stock.FLMBaseFrictionThresholds || {})) {
const override = (merged.baseFrictionThresholds || {})[family]
rows.push({
key: `friction-${family}`,
code: family,
label: `${family} friction threshold`,
curve: true,
stock: (payload && payload.values) || [],
current: (override && override.values) || (payload && payload.values) || [],
})
}
for (const [symbol, value] of Object.entries(merged.vehicleKnobs || {})) {
if (!Object.prototype.hasOwnProperty.call(stock.FLMVehicleKnobs || {}, symbol)) continue
rows.push({
key: symbol,
code: symbol,
label: symbol.split(".").slice(1).join("."),
curve: false,
stock: stock.FLMVehicleKnobs[symbol],
current: value,
})
}
return rows
},
mergedOverrides() {
const trial = this.activeTrial || {}
return {
baseFrictionThresholds: trial.appliedFrictionThresholds || {},
vehicleKnobs: trial.appliedVehicleKnobs || {},
}
},
angleControl() {
const rep = this.report
return !!(rep && rep.car && rep.car.controlPath === "angle")
},
canAnalyze() {
return this.selectedRoutes.length > 0 && !this.isOnroad && !this.laneCentering
},
canApplyTrial() {
const trial = this.activeTrial
return !this.busy && !(trial && trial.rollbackAvailable === false)
},
},
methods: {
num(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
},
fmtVal(value) {
if (Array.isArray(value)) return this.curveStr(value)
if (typeof value === "boolean") return value ? "On" : "Off"
const n = Number(value)
return Number.isFinite(n) ? n.toFixed(3) : String(value ?? "-")
},
curveStr(values) { return `[${(values || []).map((v) => Number(v).toFixed(3)).join(", ")}]` },
fmtAge(epoch) {
const at = Number(epoch)
if (!Number.isFinite(at) || at <= 0) return "unknown"
const age = Math.max(0, Math.round(Date.now() / 1000 - at))
if (age < 5) return "just now"
if (age < 60) return `${age}s ago`
if (age < 3600) return `${Math.round(age / 60)}m ago`
return `${Math.round(age / 3600)}h ago`
},
fmtDate(value) {
if (!value) return "unknown"
const n = Number(value)
if (Number.isFinite(n)) {
if (n <= 0) return "unknown"
return new Date(n > 100000000000 ? n : n * 1000).toLocaleString()
}
const d = new Date(value)
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString()
},
fmtLen(route) {
const seg = Math.max(0, Math.round(this.num(route && route.segmentCount)))
if (!seg) return "Length unavailable"
const min = Math.max(1, Math.round(this.num(route && route.approxDurationSeconds) / 60) || seg)
const dur = min >= 60 ? `${Math.floor(min / 60)}h ${min % 60}m` : `~${min} min`
return `${seg} segment${seg === 1 ? "" : "s"} (${dur})`
},
syncFeedback(report) {
const fb = (report && report.feedback) || {}
this.feedbackAccepted = Array.isArray(fb.acceptedDimensions) ? [...fb.acceptedDimensions] : []
this.feedbackIgnored = Array.isArray(fb.ignoredDimensions) ? [...fb.ignoredDimensions] : []
this.feedbackNotes = typeof fb.notes === "string" ? fb.notes : ""
},
fbState(dimensionId) {
if (this.feedbackAccepted.includes(dimensionId)) return "accepted"
if (this.feedbackIgnored.includes(dimensionId)) return "ignored"
return "unset"
},
pathList() {
const rep = this.report
if (!rep) return []
if (Array.isArray(rep.paths) && rep.paths.length) return rep.paths
return [{
key: rep.primaryPathKey || "cleanup_pass",
title: "Recommendations",
description: "",
whySelected: "",
isPrimary: true,
suggestions: rep.suggestions || [],
profiles: rep.profiles || [],
}]
},
primaryPath() {
const paths = this.pathList()
const key = (this.report && (this.report.selectedPathKey || this.report.primaryPathKey)) || ""
return paths.find((p) => p.key === key) || paths.find((p) => p.isPrimary) || paths[0] || null
},
pathSelected(key) {
const rep = this.report
const active = (rep && (rep.selectedPathKey || rep.primaryPathKey)) || ""
return key === active
},
suggestions() {
const p = this.primaryPath()
return (p && (p.suggestions || [])) || (this.report && this.report.suggestions) || []
},
profiles() {
const p = this.primaryPath()
const list = (p && (p.profiles || [])) || []
return list.length ? list : ((this.report && this.report.profiles) || [])
},
genericEntries(profile) {
return Object.entries(profile && profile.genericParams || {}).filter(([key]) => key !== "AdvancedLateralTune")
},
frictionEntries(profile) {
return Object.entries(((profile && profile.flmOverrides) || {}).baseFrictionThresholds || {})
},
knobEntries(profile) {
return Object.entries(((profile && profile.flmOverrides) || {}).vehicleKnobs || {})
},
reportTimestamp(report) {
if (!report) return ""
const at = Number(report.createdAt)
return Number.isFinite(at) && at > 1000000000 ? this.fmtDate(at) : ""
},
async runWith(fn, okMessage) {
if (this.busy) return null
this.busy = true
this.error = ""
try {
const payload = await fn()
showSnackbar(payload && payload.message ? payload.message : okMessage)
await this.refresh()
return payload
} catch (e) {
const msg = (e && (e.data?.error || e.message)) || "Action failed."
this.error = msg
showSnackbar(msg, "error")
return null
} finally {
this.busy = false
}
},
async refresh() {
try {
const ws = await api.getFlmWorkspace()
if (ws && typeof ws === "object") {
this.workspace = {
reports: Array.isArray(ws.reports) ? ws.reports : [],
savedTunes: Array.isArray(ws.savedTunes) ? ws.savedTunes : [],
currentCarFingerprint: ws.currentCarFingerprint || "",
activeTrial: ws.activeTrial || null,
status: (ws.status && typeof ws.status === "object") ? ws.status : {},
}
}
const st = await api.getFlmStatus().catch(() => null)
if (st && typeof st === "object") {
this.isOnroad = !!st.isOnroad
this.laneCentering = !!st.laneCentering
this.statusData = (st.status && typeof st.status === "object") ? st.status : null
this.workspace.savedTunes = Array.isArray(st.savedTunes) && st.savedTunes.length
? st.savedTunes : this.workspace.savedTunes
this.workspace.activeTrial = st.activeTrial || this.workspace.activeTrial
this.workspace.reports = Array.isArray(st.reports) && st.reports.length
? st.reports : this.workspace.reports
}
this.error = ""
this.loading = false
this.autoOpenReport()
} catch (e) {
this.error = (e && e.message) || "Failed to load tuning workspace."
this.loading = false
throw e
}
},
autoOpenReport() {
const statusId = this.status.reportId || ""
if (statusId && statusId !== this.lastStatusReportId) {
this.lastStatusReportId = statusId
this.loadReport(statusId)
return
}
if (!this.loadedReportId && (this.workspace.reports[0] || {}).reportId) {
this.loadReport(this.workspace.reports[0].reportId)
}
},
async loadReport(reportId) {
if (!reportId || reportId === this.loadedReportId && this.report) return
this.loadedReportId = reportId
this.reportLoading = true
try {
const report = await api.getFlmReport(reportId)
if (!report) throw new Error("Report not found.")
this.report = report
this.syncFeedback(report)
this.error = ""
} catch (e) {
this.report = null
showSnackbar((e && e.message) || "Failed to load report.", "error")
} finally {
this.reportLoading = false
}
},
async loadRoutes() {
if (this.loadingRoutes) return
this.loadingRoutes = true
this.routes = []
try {
await api.getRoutesStream({
onProgress: () => {},
onRoutes: (list) => {
for (const route of (list || [])) {
if (!route || !route.name) continue
if (this.routes.some((r) => r.name === route.name)) continue
if (this.routes.length >= MAX_ROUTES) return
this.routes.push(route)
}
},
})
} catch (e) {
showSnackbar((e && e.message) || "Failed to load routes.", "error")
} finally {
this.loadingRoutes = false
}
},
toggleRoute(name) {
const set = new Set(this.selectedRoutes)
if (set.has(name)) set.delete(name); else set.add(name)
this.selectedRoutes = [...set]
},
clearSelection() { this.selectedRoutes = [] },
async analyze() {
if (!this.canAnalyze || this.busy) return
const ok = await this.runWith(() => api.flmAnalyze(this.selectedRoutes, {}), "FLM analysis started.")
if (ok && this.selectedRoutes.length) this.selectedRoutes = []
},
async stopAnalyze() {
await this.runWith(() => api.flmStopAnalyze(), "FLM analysis stopped.")
},
async revertTrial() {
await this.runWith(() => api.flmRevertTrial(), "Trial reverted.")
},
async acceptBaseline() {
const ok = await GalaxyConfirm({
title: "Keep Current as Baseline",
message: "Keep the currently applied tuning values and end this trial? This does not restore the previous tune.",
confirmLabel: "Yes, Keep",
})
if (!ok) return
await this.runWith(() => api.flmAcceptTrial(), "Current tune kept as baseline.")
},
async applyTrial(profileId) {
const rep = this.report
if (!rep || !rep.reportId || !this.canApplyTrial) return
await this.runWith(() => api.flmApplyTrial(rep.reportId, profileId), "Trial profile applied.")
},
async selectPath(pathKey) {
const rep = this.report
if (!rep || !rep.reportId || this.pathSelected(pathKey)) return
const payload = await this.runWith(() => api.flmSelectPath(rep.reportId, pathKey), "Path selected.")
if (payload && payload.report) {
this.report = payload.report
this.syncFeedback(payload.report)
}
},
toggleFeedback(dimensionId, mode) {
const current = this.fbState(dimensionId)
const next = current === mode ? "unset" : mode
const accepted = new Set(this.feedbackAccepted)
const ignored = new Set(this.feedbackIgnored)
accepted.delete(dimensionId)
ignored.delete(dimensionId)
if (next === "accepted") accepted.add(dimensionId)
if (next === "ignored") ignored.add(dimensionId)
this.feedbackAccepted = [...accepted]
this.feedbackIgnored = [...ignored]
this.saveFeedback()
},
async saveFeedback() {
const rep = this.report
if (!rep || !rep.reportId || this.busy) return
this.busy = true
try {
const payload = await api.flmSaveFeedback(rep.reportId, {
acceptedDimensions: this.feedbackAccepted,
ignoredDimensions: this.feedbackIgnored,
notes: this.feedbackNotes,
})
if (payload && payload.report) { this.report = payload.report; this.syncFeedback(payload.report) }
showSnackbar((payload && payload.message) || "Feedback saved.")
await this.refresh()
} catch (e) {
const msg = (e && (e.data?.error || e.message)) || "Failed to save feedback."
showSnackbar(msg, "error")
} finally {
this.busy = false
}
},
async deleteReport(report) {
if (!report || !report.reportId) return
const ok = await GalaxyConfirm({
title: "Delete Report",
message: "Delete this saved tuning report and its generated trial data?",
confirmLabel: "Yes, Delete",
danger: true,
})
if (!ok) return
await this.runWith(() => api.flmDeleteReport(report.reportId), "Report deleted.")
if (this.loadedReportId === report.reportId) {
this.report = null
this.loadedReportId = ""
}
},
startPending(kind, opts = {}) {
this.pending = { kind, tuneId: opts.tuneId || "" }
this.pendingName = opts.name || ""
},
cancelPending() { this.pending = null; this.pendingName = "" },
async confirmPending() {
const p = this.pending
const name = String(this.pendingName || "").trim()
if (!p) return
if (p.kind === "save" || p.kind === "rename") {
if (!name) { showSnackbar("A name is required.", "error"); return }
}
this.pending = null
this.pendingName = ""
try {
if (p.kind === "save") {
await this.runWith(() => api.flmSaveTune(name), "Tune saved.")
} else if (p.kind === "rename") {
await this.runWith(() => api.flmRenameSavedTune(p.tuneId, name), "Tune renamed.")
} else if (p.kind === "discord") {
if (!name) { showSnackbar("A Discord username is required.", "error"); return }
await this.runWith(() => api.flmSubmitTune(p.tuneId, name), "Tune submitted to Firestar.")
}
} catch (e) { }
},
saveCurrent() {
const trial = this.activeTrial
if (!trial) return
const def = trial.profileLabel || this.workspace.currentCarFingerprint || "Saved Tune"
this.startPending("save", { name: def })
},
async deleteSavedTune(tune) {
const ok = await GalaxyConfirm({
title: "Delete Saved Tune",
message: `Delete saved tune "${tune.name || "Saved Tune"}"?`,
confirmLabel: "Yes, Delete",
danger: true,
})
if (!ok) return
await this.runWith(() => api.flmDeleteSavedTune(tune.tuneId), "Saved tune deleted.")
},
async applySavedTune(tune) {
await this.runWith(() => api.flmApplySavedTune(tune.tuneId), "Saved tune applied.")
},
async submitTune(tune) {
const ok = await GalaxyConfirm({
title: "Send to Firestar",
message: "Think this FLM tune is genuinely good and worth sharing? Only the tune values, car identity, and your Discord username are sent; routes and driving logs are not included.",
confirmLabel: "Continue",
})
if (!ok) return
this.startPending("discord", { tuneId: tune.tuneId })
},
},
template: `
<div>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-diagram-3"></i>
<span class="gx-section__title">Lateral Tuning (FLM)</span>
</div>
<div style="padding: var(--sp-4);">
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-3);">
Analyze one or more local routes, review deterministic lateral findings, apply a bounded trial, drive, then revert or refine.
</p>
<GxNotice v-if="laneCentering" title="Before using FLM:" text="Turn Lane Centering off. FLM must analyze the model's unmodified lateral request; routes recorded with Lane Centering enabled are excluded." style="margin:0 0 var(--sp-3);" />
<GxNotice v-if="isOnroad" text="FLM analysis is offroad-only. Stop the car and go offroad before starting a run." style="margin:0 0 var(--sp-3);" />
<GxNotice v-if="activeTrial && activeTrial.rollbackAvailable === false" text="The original rollback data is unavailable. Keep the current tune as the new baseline before applying another trial." style="margin:0 0 var(--sp-3);" />
<GxNotice v-if="error" tone="danger" :text="error" style="margin:0 0 var(--sp-3);" />
<div style="display:flex; gap:8px; margin-bottom: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn" :disabled="busy || !canAnalyze" @click="analyze">
<i class="bi bi-graph-up-arrow"></i> Analyze Selected
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || !status.running" @click="stopAnalyze">
<i class="bi bi-stop-fill"></i> Stop
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || !activeTrial || activeTrial.rollbackAvailable === false" @click="revertTrial">
<i class="bi bi-arrow-counterclockwise"></i> Revert Trial
</button>
<button v-if="activeTrial && activeTrial.rollbackAvailable === false" type="button" class="gx-btn" :disabled="busy" @click="acceptBaseline">
<i class="bi bi-check2-circle"></i> Keep as Baseline
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || !activeTrial" @click="saveCurrent">
<i class="bi bi-save"></i> Save Tune
</button>
<button type="button" class="gx-icon-btn" title="Refresh" :disabled="busy || loading" @click="refresh"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<div v-if="loading" class="gx-loading">Loading tuning workspace...</div>
<div class="gx-section__header" style="padding:0 0 6px;"><i class="bi bi-activity"></i><span class="gx-section__title">Workspace status</span></div>
<div>
<div class="gx-row" style="border-top:none;"><span class="gx-row__label">State</span><span class="gx-row__value">{{ status.state || 'idle' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Running</span><span class="gx-row__value">{{ status.running ? 'Yes' : 'No' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Onroad</span><span class="gx-row__value">{{ isOnroad ? 'Yes' : 'No' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Lane Centering</span><span class="gx-row__value">{{ laneCentering ? 'On' : 'Off' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Updated</span><span class="gx-row__value">{{ fmtAge(status.updatedAt) }}</span></div>
<div v-if="status.currentSegment" class="gx-row"><span class="gx-row__label">Current segment</span><span class="gx-row__value">{{ status.currentSegment }}</span></div>
<div v-if="status.lastSkippedSegment" class="gx-row"><span class="gx-row__label">Skipped</span><span class="gx-row__value">{{ status.lastSkippedSegment }}</span></div>
<div v-if="activeTrial" class="gx-row"><span class="gx-row__label">Active trial</span><span class="gx-row__value">{{ activeTrial.profileLabel || activeTrial.profileId || 'Active' }}</span></div>
<div v-if="status.running && status.total" class="gx-row"><span class="gx-row__label">Progress</span><span class="gx-row__value">{{ num(status.progress,0) }} / {{ num(status.total,0) }}</span></div>
</div>
</div>
</section>
<div v-if="pending" class="gx-card" style="margin-top: var(--sp-3);">
<div style="padding: var(--sp-4); display:grid; gap:10px;">
<div class="gx-section__header" style="padding:0 0 6px;">
<i class="bi bi-pencil-square"></i>
<span class="gx-section__title">{{ pending.kind === 'discord' ? 'Send to Firestar' : pending.kind === 'rename' ? 'Rename Tune' : 'Save Current Tune' }}</span>
</div>
<input v-if="pending.kind === 'discord'" class="gx-field gx-field--full" v-model="pendingName" placeholder="Enter your Discord username..." @keyup.enter="confirmPending" />
<input v-else class="gx-field gx-field--full" v-model="pendingName" placeholder="Name this tune..." @keyup.enter="confirmPending" />
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" class="gx-btn gx-btn--text" @click="cancelPending">Cancel</button>
<button type="button" class="gx-btn" :disabled="busy" @click="confirmPending"><i class="bi bi-check2-circle"></i> Confirm</button>
</div>
</div>
</div>
<section class="gx-card" style="margin-top: var(--sp-3);">
<div class="gx-section__header">
<i class="bi bi-journal-arrow-down"></i>
<span class="gx-section__title">Local Routes</span>
</div>
<div style="padding: var(--sp-4);">
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-2);">Pick up to 8 routes to analyze. Whole routes are used.</p>
<div v-if="loadingRoutes" class="gx-loading">Loading local routes...</div>
<div v-else-if="!routes.length" class="gx-empty">No local routes found.</div>
<div v-else>
<label class="gx-chip" style="cursor:pointer;" :style="'user-select:none;'">
<input type="checkbox" :checked="selectedRoutes.length === routes.length" style="margin-right:6px;" @change="selectedRoutes = (selectedRoutes.length === routes.length) ? [] : routes.map(r => r.name)" />
Select all
</label>
<button type="button" class="gx-btn gx-btn--text" style="font-size:var(--fs-xs);" @click="clearSelection">Clear</button>
<div v-for="route in routes" :key="route.name" style="border-top:1px solid var(--glass-border); padding: var(--sp-2) 0;">
<label style="display:flex; gap:10px; align-items:flex-start; cursor:pointer;">
<input type="checkbox" :checked="selectedRoutes.includes(route.name)" @change="toggleRoute(route.name)" style="margin-top:4px;" />
<span style="min-width:0;">
<strong>{{ fmtDate(route.timestamp) }}</strong>
<div class="gx-row__desc" style="word-break:break-all;">{{ route.name }}</div>
<div class="gx-row__desc">{{ fmtLen(route) }}</div>
</span>
</label>
</div>
</div>
</div>
</section>
<section class="gx-card" style="margin-top: var(--sp-3);">
<div class="gx-section__header">
<i class="bi bi-sliders"></i>
<span class="gx-section__title">Reports</span>
</div>
<div style="padding: var(--sp-4);">
<div v-if="loading || !workspace.reports.length" class="gx-empty">No reports yet. Analyze routes to generate findings.</div>
<div v-for="report in workspace.reports" :key="report.reportId" class="gx-row" style="cursor:pointer;" @click="loadReport(report.reportId)">
<span class="gx-row__label">{{ report.carFingerprint || 'Unknown car' }}<span class="gx-row__desc">{{ fmtDate(report.createdAt) }}<span v-if="report.controlPath"> / {{ report.controlPath }}</span></span></span>
<span class="gx-row__value">
<span v-if="loadedReportId === report.reportId" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Open</span>
<span v-else>Open</span>
</span>
</div>
</div>
</section>
<section v-if="report && !reportLoading" class="gx-card" style="margin-top: var(--sp-3);">
<div class="gx-section__header">
<i class="bi bi-clipboard-data"></i>
<span class="gx-section__title">Report Summary</span>
<button type="button" class="gx-icon-btn" title="Delete report" style="color:var(--error); margin-left:auto;" @click="deleteReport(report)"><i class="bi bi-trash"></i></button>
</div>
<div style="padding: var(--sp-4);">
<div>
<div class="gx-row" style="border-top:none;"><span class="gx-row__label">Car</span><span class="gx-row__value">{{ report.car && report.car.carFingerprint || 'Unknown' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Control path</span><span class="gx-row__value">{{ report.car && report.car.controlPath || 'unknown' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Created</span><span class="gx-row__value">{{ reportTimestamp(report) }}</span></div>
<div class="gx-row"><span class="gx-row__label">Samples</span><span class="gx-row__value">{{ num(report.summary && report.summary.sampleCount) }}</span></div>
<div class="gx-row"><span class="gx-row__label">Processed segments</span><span class="gx-row__value">{{ num(report.summary && report.summary.processedSegments) }}</span></div>
<div v-if="report.summary && report.summary.usedQlogFallback" class="gx-row"><span class="gx-row__label">qlog fallback</span><span class="gx-row__value">Yes</span></div>
</div>
<div v-if="comparisonRows.length" style="margin-top: var(--sp-3);">
<div class="gx-section__header" style="padding:0 0 6px;">
<i class="bi bi-git-compare"></i>
<span class="gx-section__title">{{ angleControl ? 'Applicable Angle Settings' : 'Stock vs Current FLM' }}</span>
</div>
<div v-if="!angleControl" class="gx-row__desc" style="margin-bottom: 6px;">Active trial values are shown in the current column when one is applied.</div>
<div v-for="row in comparisonRows" :key="row.key" class="gx-row">
<span class="gx-row__label" :title="row.code || row.key">{{ row.label }}</span>
<span class="gx-row__value" style="text-align:right;">
<span class="gx-row__desc">{{ row.curve ? 'stock ' + curveStr(row.stock) : 'stock ' + fmtVal(row.stock) }}</span>
<span style="color:var(--primary);">{{ row.curve ? curveStr(row.current) : fmtVal(row.current) }}</span>
</span>
</div>
</div>
<div v-for="path in pathList()" :key="path.key" style="margin-top: var(--sp-3);">
<div class="gx-section__header" style="padding:0 0 6px;"><i class="bi bi-signpost-split"></i><span class="gx-section__title">{{ path.title }}</span></div>
<p v-if="path.description" style="color:var(--text-muted); margin:0 0 6px; line-height:1.5;">{{ path.description }}</p>
<p v-if="path.whySelected" style="margin:0 0 6px; line-height:1.5;"><strong>Why this path:</strong> {{ path.whySelected }}</p>
<button v-if="!pathSelected(path.key) && pathList().length > 1" type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="selectPath(path.key)">Use this path</button>
</div>
<div v-if="(report.addTheseParametersAndStartHere || []).length" style="margin-top: var(--sp-3);">
<div class="gx-section__header" style="padding:0 0 6px;"><i class="bi bi-bezier2"></i><span class="gx-section__title">Add these and start here</span></div>
<ul style="margin:0; padding-left:18px; line-height:1.7; color:var(--text-muted);"><li v-for="line in report.addTheseParametersAndStartHere" :key="line">{{ line }}</li></ul>
</div>
<div v-if="(report.warnings || []).length" style="margin-top: var(--sp-3);">
<div class="gx-section__header" style="padding:0 0 6px;"><i class="bi bi-exclamation-triangle"></i><span class="gx-section__title">Warnings</span></div>
<ul style="margin:0; padding-left:18px; line-height:1.7; color:var(--text-muted);"><li v-for="w in report.warnings" :key="w">{{ w }}</li></ul>
</div>
</div>
</section>
<section v-if="report && !reportLoading" class="gx-card" style="margin-top: var(--sp-3);">
<div class="gx-section__header">
<i class="bi bi-list-check"></i>
<span class="gx-section__title">Findings &amp; feedback</span>
</div>
<div style="padding: var(--sp-4);">
<div class="gx-row__desc" style="margin-bottom: var(--sp-2);">Finding decisions save immediately and regenerate trial profiles.</div>
<textarea class="gx-field gx-field--full" rows="2" style="resize:vertical; min-height:48px;" placeholder="Optional tuning notes" v-model="feedbackNotes"></textarea>
<div style="display:flex; justify-content:flex-end; margin: var(--sp-2) 0 var(--sp-3);">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="saveFeedback"><i class="bi bi-bookmark-check"></i> Save Notes</button>
</div>
<div v-if="!suggestions().length" class="gx-empty">No findings for the active path.</div>
<div v-for="s in suggestions()" :key="s.dimensionId || s.bucket" style="border:1px solid var(--glass-border); border-radius:var(--radius-lg); padding: var(--sp-3); margin-bottom: var(--sp-3);">
<div class="gx-section__header" style="padding:0 0 6px;"><i class="bi bi-bullseye"></i><span class="gx-section__title">{{ (s.bucket || 'finding').replace(/_/g, ' ') }}</span></div>
<p v-if="s.evidence && (s.evidence.speedBand || s.evidence.directionBias || s.evidence.eventCount)" style="margin:0 0 6px; color:var(--text-muted); font-size:var(--fs-xs);">
{{ s.evidence.speedBand || 'mixed' }} | {{ s.evidence.directionBias || 'center' }} | {{ num(s.evidence.eventCount) }} event(s)
</p>
<p v-if="s.observedBehavior" style="margin:0 0 6px; line-height:1.5;"><strong>Observed:</strong> {{ s.observedBehavior }}</p>
<p v-if="s.likelyInterpretation" style="margin:0 0 6px; line-height:1.5;"><strong>Interpretation:</strong> {{ s.likelyInterpretation }}</p>
<p v-if="s.primaryAdjustment" style="margin:0 0 6px; line-height:1.5;"><strong>Primary adjustment:</strong> {{ s.primaryAdjustment }}</p>
<p v-if="s.whatNotToTouchYet" style="margin:0 0 6px; line-height:1.5;"><strong>What not to touch yet:</strong> {{ s.whatNotToTouchYet }}</p>
<p v-if="s.ifThatWasWrong" style="margin:0 0 6px; line-height:1.5;"><strong>Next thing to try:</strong> {{ s.ifThatWasWrong }}</p>
<div v-if="s.currentVsSuggested" style="border-top:1px solid var(--glass-border); padding-top: var(--sp-2);">
<strong style="font-size:var(--fs-sm);">Current vs suggested:</strong>
<p v-if="s.currentVsSuggested.type === 'friction_curve'" style="margin:4px 0 0; color:var(--text-muted); font-size:var(--fs-sm); word-break:break-all;">
<code>{{ s.currentVsSuggested.family }}</code> {{ curveStr(s.currentVsSuggested.current) }} &rarr; {{ curveStr(s.currentVsSuggested.suggested) }}
</p>
<p v-else style="margin:4px 0 0; color:var(--text-muted); font-size:var(--fs-sm); word-break:break-all;">
<code>{{ s.currentVsSuggested.paramKey || s.currentVsSuggested.symbol }}</code> {{ fmtVal(s.currentVsSuggested.current) }} &rarr; {{ fmtVal(s.currentVsSuggested.suggested) }}
</p>
</div>
<div style="display:flex; gap:8px; margin-top: var(--sp-2); flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" :style="fbState(s.dimensionId) === 'accepted' ? 'background:var(--primary);color:var(--on-primary);' : ''" @click="toggleFeedback(s.dimensionId, 'accepted')">{{ fbState(s.dimensionId) === 'accepted' ? 'Matched' : 'Matches Experience' }}</button>
<button type="button" class="gx-btn gx-btn--text" :disabled="busy" :style="fbState(s.dimensionId) === 'ignored' ? 'color:var(--error);' : ''" @click="toggleFeedback(s.dimensionId, 'ignored')">{{ fbState(s.dimensionId) === 'ignored' ? 'Ignored' : 'Ignore' }}</button>
</div>
</div>
</div>
</section>
<section class="gx-card" style="margin-top: var(--sp-3);">
<div class="gx-section__header">
<i class="bi bi-flask"></i>
<span class="gx-section__title">Trial Profiles</span>
<button type="button" class="gx-btn gx-btn--text" style="margin-left:auto;" :disabled="busy || !activeTrial" @click="saveCurrent">Save Current</button>
</div>
<div style="padding: var(--sp-4);">
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-3);">Apply one bounded profile at a time. Revert restores the exact advanced-lateral and FLM state from before the trial.</p>
<div v-if="reportLoading" class="gx-loading">Loading report profiles...</div>
<div v-else-if="report && !profiles().length" class="gx-empty">No trial profiles for this report.</div>
<div v-if="!report" class="gx-empty">Open a report to view and apply its trial profiles.</div>
<div v-for="profile in profiles()" :key="profile.id" style="border:1px solid var(--glass-border); border-radius:var(--radius-lg); padding: var(--sp-3); margin-bottom: var(--sp-3);">
<div class="gx-section__header" style="padding:0 0 6px;">
<span class="gx-section__title">{{ profile.label }}</span>
<button type="button" class="gx-btn" style="margin-left:auto;" :disabled="busy || !canApplyTrial" @click="applyTrial(profile.id)"><i class="bi bi-play-fill"></i> Apply Trial</button>
</div>
<p v-if="profile.description" style="margin:0 0 var(--sp-2); color:var(--text-muted); line-height:1.5;">{{ profile.description }}</p>
<div v-if="genericEntries(profile).length || frictionEntries(profile).length || knobEntries(profile).length">
<span v-for="[key, value] in genericEntries(profile)" :key="key" class="gx-chip" style="margin:2px;"><code>{{ key }}</code>: {{ fmtVal(value) }}</span>
<span v-for="[family, payload] in frictionEntries(profile)" :key="family" class="gx-chip" style="margin:2px;"><code>{{ family }}</code>: {{ curveStr(payload && payload.values || []) }}</span>
<span v-for="[symbol, value] in knobEntries(profile)" :key="symbol" class="gx-chip" style="margin:2px;"><code>{{ symbol }}</code>: {{ fmtVal(value) }}</span>
</div>
<div v-else class="gx-row__desc" style="margin-top:4px;">No parameter overrides in this profile.</div>
</div>
</div>
</section>
<section class="gx-card" style="margin-top: var(--sp-3);">
<div class="gx-section__header">
<i class="bi bi-collection"></i>
<span class="gx-section__title">Saved Tunes</span>
</div>
<div style="padding: var(--sp-4);">
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-3);">Save a working FLM trial, switch between setups, then use Revert Trial to return to the exact manual settings from before FLM.</p>
<div v-if="!workspace.savedTunes.length" class="gx-empty">No saved tunes yet. Apply a trial, then save it here.</div>
<div v-for="tune in workspace.savedTunes" :key="tune.tuneId" style="border-top:1px solid var(--glass-border); padding: var(--sp-2) 0;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
<div style="min-width:0;">
<strong>{{ tune.name || 'Saved Tune' }}<span v-if="tune.active" style="color:var(--primary);"> (Active)</span></strong>
<div class="gx-row__desc">{{ tune.carFingerprint || 'Unknown car' }}{{ tune.pathLabel ? ' / ' + tune.pathLabel : '' }}</div>
<div class="gx-row__desc">{{ tune.genericParamCount }} generic, {{ tune.frictionCurveCount }} friction curve, {{ tune.vehicleKnobCount }} knobs</div>
<div class="gx-row__desc">{{ fmtAge(tune.updatedAt) }}</div>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || tune.active" @click="applySavedTune(tune)">{{ tune.active ? 'Active' : 'Apply' }}</button>
<button type="button" class="gx-btn gx-btn--text" :disabled="busy" @click="startPending('rename', { tuneId: tune.tuneId, name: tune.name })">Rename</button>
<button type="button" class="gx-btn gx-btn--text" :disabled="busy || tune.active" style="color:var(--error);" @click="deleteSavedTune(tune)">Delete</button>
<button type="button" class="gx-btn gx-btn--text" :disabled="busy" @click="submitTune(tune)">Firestar</button>
</div>
</div>
</div>
</div>
</section>
</div>
`,
}
@@ -0,0 +1,162 @@
import { api, showSnackbar } from "../api.js"
import { usePolling, formatAgeSeconds } from "../composables.js"
function num(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
function rowsOf(items) {
return items
}
export const LongitudinalManeuvers = {
name: "LongitudinalManeuvers",
data() {
return { loading: true, busy: false, error: "", data: null }
},
created() {
this.poll = usePolling(() => this.refresh(), { interval: 3000 })
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
computed: {
fmt() {
const data = this.data
if (!data) return null
const support = data.support && typeof data.support === "object" ? data.support : {}
const yes = (k) => (support[k] ? "Yes" : "No")
const number = (k, unit) => {
const v = Number(support[k])
return Number.isFinite(v) ? `${v.toFixed(2)} ${unit}` : "n/a"
}
const rows = [
{ label: "Mode Enabled", value: data.modeEnabled ? "Yes" : "No" },
{ label: "State", value: data.state || "idle" },
{ label: "Onroad", value: data.isOnroad ? "Yes" : "No" },
{ label: "Engaged", value: data.isEngaged ? "Yes" : "No" },
{ label: "Phase", value: data.phase || "n/a" },
{ label: "Paddle Mode", value: data.paddleMode || "auto" },
{ label: "Step", value: `${num(data.stepIndex, 0)} / ${num(data.stepTotal, 0)}` },
{ label: "Run", value: `${num(data.runIndex, 0)} / ${num(data.runTotal, 0)}` },
{ label: "Updated", value: formatAgeSeconds(data.updatedAgeSec) },
]
const supportRows = [
{ label: "openpilot Longitudinal", value: yes("openpilotLongitudinalControl") },
{ label: "Full Stop + Go", value: yes("fullStopAndGo") },
{ label: "Auto Resume from Stop", value: yes("autoResumeFromStop") },
{ label: "Requires Resume Assist", value: yes("requiresResumeAssist") },
{ label: "Expected to Reach Zero", value: yes("expectedToReachZero") },
{ label: "Minimum Enable Speed", value: number("minEnableSpeed", "m/s") },
{ label: "Stop Acceleration", value: number("stopAccel", "m/s²") },
]
return {
rows: rowsOf(rows),
supportRows: rowsOf(supportRows),
popup: `${data.uiText1 || ""}${data.uiText2 ? ` | ${data.uiText2}` : ""}`,
caveats: Array.isArray(data.caveats) ? data.caveats : [],
skipped: Array.isArray(data.skippedManeuvers) ? data.skippedManeuvers : [],
history: Array.isArray(data.history) ? data.history : [],
}
},
},
methods: {
async refresh() {
try {
const payload = await api.longitudinalManeuversStatus()
this.data = payload && typeof payload === "object" ? payload : null
this.error = ""
this.loading = false
} catch (e) {
this.error = e?.message || "Failed to load maneuver status"
this.loading = false
throw e
}
},
async run(action) {
if (this.busy) return
this.busy = true
try {
const payload = await api.longitudinalManeuvers(action)
this.data = payload && typeof payload === "object" ? payload : this.data
this.error = ""
showSnackbar(payload?.message || "Action complete.")
} catch (e) {
this.error = e?.message || `Failed to ${action} maneuvers`
showSnackbar(this.error, "error")
} finally {
this.busy = false
}
},
},
template: `
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-forward-fill"></i>
<span class="gx-section__title">Long Maneuvers</span>
</div>
<div style="padding: var(--sp-4);">
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-3);">Run the longitudinal maneuver suite from your phone and monitor progress live.</p>
<div style="display:flex; gap:8px; margin-bottom: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn" :disabled="busy" @click="run('start')">
<i class="bi bi-play-fill"></i> Start / Arm
</button>
<button type="button" class="gx-btn gx-btn--danger" :disabled="busy" @click="run('stop')">
<i class="bi bi-stop-fill"></i> Stop
</button>
</div>
<p v-if="error" class="gx-alert gx-alert--warn" style="border:none;">{{ error }}</p>
<div v-if="loading" class="gx-loading">Loading status...</div>
<template v-if="!loading && fmt">
<div class="gx-section__header" style="padding:0 0 6px;"><i class="bi bi-activity"></i><span class="gx-section__title">Live status</span></div>
<div>
<div v-for="row in fmt.rows" style="border-top:none;" :key="row.label" class="gx-row">
<span class="gx-row__label">{{ row.label }}</span>
<span class="gx-row__value">{{ row.value }}</span>
</div>
</div>
<div class="gx-section__header" style="padding: var(--sp-4) 0 6px; margin-top: var(--sp-3);"><i class="bi bi-flag"></i><span class="gx-section__title">Current maneuver</span></div>
<p style="margin:0; font-weight:var(--fw-bold);">{{ data?.maneuver || 'n/a' }}</p>
<p v-if="fmt.popup" style="color:var(--text-muted); margin:4px 0 0;">{{ fmt.popup }}</p>
<div class="gx-section__header" style="padding: var(--sp-4) 0 6px; margin-top: var(--sp-3);"><i class="bi bi-check2-circle"></i><span class="gx-section__title">Longitudinal support</span></div>
<div>
<div v-for="row in fmt.supportRows" style="border-top:none;" :key="row.label" class="gx-row">
<span class="gx-row__label">{{ row.label }}</span>
<span class="gx-row__value">{{ row.value }}</span>
</div>
</div>
<div v-if="fmt.caveats.length" class="gx-alert gx-alert--warn" style="margin: var(--sp-3) 0 0;">
<i class="bi bi-exclamation-triangle gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Platform caveats</strong><ul style="margin:4px 0 0; padding-left:18px;"><li v-for="c in fmt.caveats" :key="c">{{ c }}</li></ul></div>
</div>
<div v-if="fmt.skipped.length" class="gx-alert gx-alert--info" style="margin: var(--sp-2) 0 0;">
<i class="bi bi-skip-forward gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Expected skips</strong><ul style="margin:4px 0 0; padding-left:18px;"><li v-for="s in fmt.skipped" :key="s">{{ s }}</li></ul></div>
</div>
<div class="gx-section__header" style="padding: var(--sp-4) 0 6px; margin-top: var(--sp-3);"><i class="bi bi-book"></i><span class="gx-section__title">Quick guide</span></div>
<ol style="margin:0; padding:0 0 0 18px; line-height:1.8; color:var(--text-muted);">
<li>Find a large, empty, straight road or lot with no traffic.</li>
<li>Press <strong>Start / Arm</strong>, then engage openpilot with SET.</li>
<li>Keep full supervision and be ready to disengage at all times.</li>
<li>Review the platform caveats first. Low-speed maneuvers can be skipped automatically on cars that do not fully stop.</li>
<li>When the status says complete, collect the logs and generate your HTML report.</li>
</ol>
<div v-if="fmt.history.length" style="margin-top: var(--sp-3);">
<div class="gx-section__header" style="padding:0 0 6px;"><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); border:1px solid var(--glass-border); border-radius:var(--radius-lg); line-height:1.7;">
<li v-for="line in [...fmt.history].reverse()" :key="line">{{ line }}</li>
</ol>
</div>
</template>
</div>
</section>
`,
}
@@ -63,7 +63,7 @@ export const ManeuverCard = {
<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>
<button type="button" class="gx-btn gx-btn--danger" :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;">
@@ -0,0 +1,458 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "./GalaxyModal.js"
import { GxNotice } from "./GxNotice.js"
const STATUS_DEFAULTS = {
cancelling: false,
downloading: false,
isOnroad: false,
lastUpdate: "Never",
mapsPresent: false,
selectedCount: 0,
storageBytes: 0,
storageKnown: false,
scheduleLabel: "Monthly",
downloadProgress: {},
}
const PROGRESS_DEFAULTS = {
active: false,
cancelled: false,
completed: false,
downloadedBytes: 0,
downloadedFiles: 0,
estimatedDownloadBytes: 0,
estimateSource: "",
etaSeconds: 0,
percent: 0,
phase: "idle",
primaryLocation: "",
storageKnown: false,
totalFiles: 0,
}
function toProgress(value) {
const p = value && typeof value === "object" ? value : {}
return { ...PROGRESS_DEFAULTS, ...p }
}
function uniqueSorted(values) {
return [...new Set(values)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
}
export const MapsPanel = {
name: "MapsPanel",
components: { GxNotice },
data() {
return {
loadingCatalog: true,
loadingStatus: true,
statusInFlight: false,
actionBusy: false,
savingSelection: false,
savingSchedule: false,
error: "",
search: "",
sections: [],
scheduleOptions: [],
status: { ...STATUS_DEFAULTS },
progress: { ...PROGRESS_DEFAULTS },
selectedSaved: [],
selectedDraft: [],
scheduleSaved: "2",
scheduleDraft: "2",
tokenLabels: {},
expandedGroups: {},
}
},
created() {
this.poll = usePolling(() => this.refreshStatus(), { interval: 2500 })
this.poll.start()
this.loadCatalog()
},
beforeUnmount() { this.poll?.destroy() },
computed: {
filteredSections() {
const query = (this.search || "").trim().toLowerCase()
const visibleRegions = (regions) => {
if (!query) return regions || []
return (regions || []).filter((r) =>
`${r.label} ${r.code} ${r.token}`.toLowerCase().includes(query))
}
return (this.sections || [])
.map((section) => ({
...section,
groups: (section.groups || [])
.map((group) => ({
...group,
expandKey: `${section.key}:${group.key}`,
visibleRegions: visibleRegions(group.regions),
}))
.filter((group) => group.visibleRegions.length > 0),
}))
.filter((section) => section.groups.length > 0)
},
selectionDirty() {
return !this.sameSorted(this.selectedDraft, this.selectedSaved)
},
scheduleDirty() {
return String(this.scheduleDraft) !== String(this.scheduleSaved)
},
showProgress() {
const p = this.status.downloading
|| this.progress.completed
|| this.progress.cancelled
|| this.progress.estimatedDownloadBytes > 0
return this.loadingStatus ? false : p
},
downloaderLabel() {
if (this.loadingStatus) return "Checking..."
if (this.status.downloading) return this.status.cancelling ? "Cancelling" : "Downloading"
return "Idle"
},
storageLabel() {
return this.status.storageKnown ? this.formatBytes(this.status.storageBytes) : "Calculating…"
},
estimateLabel() {
const p = this.progress
if (!this.selectionDirty && p.estimatedDownloadBytes > 0) {
return `~${this.formatBytes(p.estimatedDownloadBytes)} additional`
}
return this.selectedDraft.length > 0 ? "Not yet available" : "Select regions"
},
canDownload() {
return !this.loadingStatus && !this.actionBusy && !this.status.isOnroad
&& !this.status.downloading && this.selectedDraft.length > 0
},
canCancel() {
return !this.loadingStatus && !this.actionBusy && this.status.downloading
},
canRemove() {
return !this.loadingStatus && !this.actionBusy && !this.status.downloading
&& !this.status.isOnroad && this.status.mapsPresent
},
},
methods: {
sameSorted(a, b) {
if (a.length !== b.length) return false
const sa = uniqueSorted(a)
const sb = uniqueSorted(b)
return sa.every((x, i) => x === sb[i])
},
formatBytes(bytes) {
const value = Number(bytes || 0)
if (value <= 0) return "0 MB"
const units = ["B", "KB", "MB", "GB", "TB"]
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
const scaled = value / (1024 ** index)
return `${scaled >= 10 || index === 0 ? scaled.toFixed(0) : scaled.toFixed(2)} ${units[index]}`
},
formatDuration(seconds) {
const value = Math.max(0, Math.round(Number(seconds || 0)))
if (value < 60) return `${value}s`
const minutes = Math.floor(value / 60)
if (minutes < 60) return `${minutes}m ${value % 60}s`
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
},
tokenLabel(token) {
return this.tokenLabels[token] || token
},
groupSelectedCount(group) {
const tokens = new Set((group.regions || []).map((r) => r.token))
return this.selectedDraft.filter((t) => tokens.has(t)).length
},
async loadCatalog() {
try {
const payload = await api.getMapsCatalog()
this.sections = Array.isArray(payload?.sections) ? payload.sections : []
this.scheduleOptions = Array.isArray(payload?.scheduleOptions) ? payload.scheduleOptions : []
const labels = {}
for (const section of this.sections) {
for (const group of section.groups || []) {
for (const region of group.regions || []) labels[region.token] = region.label
}
}
this.tokenLabels = labels
} catch (e) {
this.error = e?.message || "Failed to load the map catalog."
} finally {
this.loadingCatalog = false
}
},
async refreshStatus() {
if (this.statusInFlight) return
this.statusInFlight = true
try {
const payload = await api.getMapsStatus()
if (!payload) return
const wasSelDirty = this.selectionDirty
const wasSchDirty = this.scheduleDirty
this.status = { ...STATUS_DEFAULTS, ...payload }
this.progress = toProgress(payload.downloadProgress)
const locs = Array.isArray(payload.selectedLocations) ? payload.selectedLocations.slice() : []
this.selectedSaved = uniqueSorted(locs)
if (!wasSelDirty) this.selectedDraft = [...this.selectedSaved]
this.scheduleSaved = String(payload.scheduleValue ?? this.scheduleSaved)
if (!wasSchDirty) this.scheduleDraft = this.scheduleSaved
this.error = ""
} catch (e) {
this.error = e?.message || "Failed to load map status."
} finally {
this.loadingStatus = false
this.statusInFlight = false
}
},
toggleToken(token, enabled) {
const next = new Set(this.selectedDraft)
if (enabled) next.add(token)
else next.delete(token)
this.selectedDraft = [...next]
},
isGroupOpen(group) {
return !!String(this.search || "").trim() || !!this.expandedGroups[group.expandKey]
},
toggleGroup(group) {
const key = group.expandKey
this.expandedGroups = { ...this.expandedGroups, [key]: !this.expandedGroups[key] }
},
setGroup(group, enabled) {
const next = new Set(this.selectedDraft)
for (const region of group.regions || []) {
if (enabled) next.add(region.token)
else next.delete(region.token)
}
this.selectedDraft = [...next]
},
resetDraft() {
this.selectedDraft = [...this.selectedSaved]
this.scheduleDraft = this.scheduleSaved
},
clearAll() {
this.selectedDraft = []
},
async saveSelection() {
if (this.savingSelection || !this.selectionDirty) return
this.savingSelection = true
try {
const payload = await api.mapsOp("selection", { selectedLocations: this.selectedDraft })
showSnackbar(payload?.message || "Map selection saved.")
await this.refreshStatus()
} catch (e) {
showSnackbar(e?.message || "Failed to save map selection.", "error")
} finally {
this.savingSelection = false
}
},
async saveSchedule() {
if (this.savingSchedule || !this.scheduleDirty) return
this.savingSchedule = true
try {
const payload = await api.mapsOp("schedule", { schedule: this.scheduleDraft })
showSnackbar(payload?.message || "Map schedule updated.")
await this.refreshStatus()
} catch (e) {
showSnackbar(e?.message || "Failed to update map schedule.", "error")
} finally {
this.savingSchedule = false
}
},
async startDownload() {
if (this.actionBusy || this.status.downloading || this.status.isOnroad || this.selectedDraft.length === 0) return
this.actionBusy = true
try {
const payload = await api.mapsOp("download", {
selectedLocations: this.selectedDraft,
schedule: this.scheduleDraft,
})
showSnackbar(payload?.message || "Map download started.")
await this.refreshStatus()
} catch (e) {
showSnackbar(e?.message || "Failed to start map download.", "error")
} finally {
this.actionBusy = false
}
},
async cancelDownload() {
if (this.actionBusy || !this.status.downloading) return
this.actionBusy = true
try {
const payload = await api.mapsOp("cancel")
showSnackbar(payload?.message || "Map download cancellation requested.")
await this.refreshStatus()
} catch (e) {
showSnackbar(e?.message || "Failed to cancel map download.", "error")
} finally {
this.actionBusy = false
}
},
async removeMaps() {
if (this.actionBusy || this.status.downloading || this.status.isOnroad || !this.status.mapsPresent) return
const ok = await GalaxyConfirm({
title: "Remove Maps",
message: "Are you sure you want to remove all downloaded offline maps?",
confirmLabel: "Yes, Remove",
danger: true,
})
if (!ok) return
this.actionBusy = true
try {
const payload = await api.mapsOp("remove")
showSnackbar(payload?.message || "Maps removed.")
await this.refreshStatus()
} catch (e) {
showSnackbar(e?.message || "Failed to remove maps.", "error")
} finally {
this.actionBusy = false
}
},
},
template: `
<div style="display:grid; gap:12px;">
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-map"></i>
<span class="gx-section__title">Offline Maps</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<p style="margin:0; color:var(--text-muted);">Select regions, start downloads, and manage offline maps entirely from Galaxy.</p>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Downloader</span>
<span class="gx-row__value">{{ downloaderLabel }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Saved Regions</span>
<span class="gx-row__value">{{ loadingStatus ? 'Checking...' : status.selectedCount }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Additional Storage</span>
<span class="gx-row__value">{{ estimateLabel }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Last Updated</span>
<span class="gx-row__value">{{ status.lastUpdate || 'Never' }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Storage Used</span>
<span class="gx-row__value">{{ storageLabel }}</span>
</div>
<GxNotice v-if="status.isOnroad" text="Map downloads and removal are blocked while driving." style="margin:8px 0;" />
<GxNotice v-if="selectionDirty" text="You have unsaved region changes. Downloading now will use the current selection." style="margin:8px 0;" />
<GxNotice v-if="scheduleDirty" text="You have an unsaved schedule change. Downloading now will also apply it." style="margin:8px 0;" />
<div style="display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; align-items:center;">
<button type="button" class="gx-btn" :disabled="!canDownload && !canCancel" @click="status.downloading ? cancelDownload() : startDownload()">
<i class="bi" :class="status.downloading ? 'bi-x-circle' : 'bi-download'"></i>
{{ status.downloading ? (status.cancelling ? 'Cancelling...' : 'Cancel Download') : 'Download Maps' }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="!canRemove" @click="removeMaps">Remove Maps</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="savingSelection || loadingCatalog || !selectionDirty" @click="saveSelection">
{{ savingSelection ? 'Saving...' : 'Save Selection' }}
</button>
<button type="button" class="gx-btn gx-btn--text" :disabled="!selectionDirty && !scheduleDirty" @click="resetDraft">Reset</button>
<button type="button" class="gx-btn gx-btn--text" :disabled="selectedDraft.length === 0" @click="clearAll">Clear All</button>
</div>
</div>
</section>
<section v-if="showProgress" class="gx-card">
<div class="gx-section__header">
<i class="bi bi-arrow-down-circle"></i>
<span class="gx-section__title">{{ status.downloading ? 'Download Progress' : progress.completed ? 'Last Download' : progress.cancelled ? 'Download Cancelled' : 'Download Estimate' }}</span>
<span class="gx-row__value">{{ Math.round(progress.percent) }}%</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<div style="height:8px; border-radius:4px; background:var(--glass-border, rgba(127,127,127,.2)); overflow:hidden;">
<div :style="'width:' + Math.round(progress.percent) + '%;height:100%;background:var(--primary);transition:width .3s;'"></div>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Estimate</span>
<span class="gx-row__value">{{ progress.estimatedDownloadBytes > 0 ? '~' + formatBytes(progress.estimatedDownloadBytes) + ' additional storage' : 'Storage estimate unavailable' }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Stored</span>
<span class="gx-row__value">{{ progress.downloadedBytes > 0 ? formatBytes(progress.downloadedBytes) + ' added storage' : 'Storage reconciles after completion' }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Files</span>
<span class="gx-row__value">{{ progress.totalFiles > 0 ? progress.downloadedFiles + ' / ' + progress.totalFiles + ' files' : 'Waiting for map service...' }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">ETA</span>
<span class="gx-row__value">{{ status.downloading && progress.etaSeconds > 0 ? 'About ' + formatDuration(progress.etaSeconds) + ' remaining' : 'ETA unavailable until files start arriving' }}</span>
</div>
<div v-if="progress.primaryLocation" class="gx-row__desc">Current region: {{ progress.primaryLocation }}</div>
</div>
</section>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-sliders"></i>
<span class="gx-section__title">Regions &amp; Schedule</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<div class="gx-row" style="border-top:none; flex-wrap:wrap;">
<span class="gx-row__label">Search</span>
<input class="gx-field" style="flex:1; min-width:180px;" type="search" v-model="search" placeholder="Filter by name or code" />
</div>
<div class="gx-row" style="border-top:none; flex-wrap:wrap;">
<span class="gx-row__label">Auto Update</span>
<select class="gx-field" style="flex:1; min-width:160px;" :value="scheduleDraft" @change="scheduleDraft = $event.target.value">
<option v-for="opt in scheduleOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="savingSchedule || !scheduleDirty" @click="saveSchedule">
{{ savingSchedule ? 'Applying...' : 'Apply' }}
</button>
</div>
<div class="gx-row" style="border-top:none; flex-wrap:wrap;">
<span class="gx-row__label">Selected Regions</span>
<div style="display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; flex:1;">
<template v-if="selectedDraft.length">
<span v-for="token in selectedDraft" :key="token" class="gx-chip">{{ tokenLabel(token) }}</span>
</template>
<span v-else class="gx-row__desc" style="margin:0;">No regions selected.</span>
</div>
</div>
<div class="gx-note" style="margin:0;">Saved schedule: {{ status.scheduleLabel }} · Draft regions: {{ selectedDraft.length }}</div>
</div>
</section>
<div v-if="loadingCatalog" class="gx-loading">Loading map catalog...</div>
<div v-else-if="filteredSections.length === 0" class="gx-empty">No regions match the current search.</div>
<section v-for="section in filteredSections" :key="section.key" class="gx-card">
<div class="gx-section__header">
<i class="bi bi-globe"></i>
<span class="gx-section__title">{{ section.title }}</span>
<span class="gx-section__count">{{ section.groups.length }}</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:12px; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));">
<div v-for="group in section.groups" :key="group.key" class="gx-card" style="margin:0; align-self:start;">
<div class="gx-section__header" role="button" tabindex="0" style="min-height:0; padding:var(--sp-2) var(--sp-3);"
@click="toggleGroup(group)" @keydown.enter="toggleGroup(group)" @keydown.space.prevent="toggleGroup(group)">
<span class="gx-section__title" style="font-size:var(--fs-base);">{{ group.title }}</span>
<span class="gx-section__count">{{ groupSelectedCount(group) }} selected</span>
<i class="bi bi-chevron-down gx-chevron" :class="{ open: isGroupOpen(group) }"></i>
</div>
<transition name="gx-collapse">
<div v-show="isGroupOpen(group)" style="padding:0 var(--sp-3) var(--sp-2);">
<div style="display:flex; gap:8px; padding:var(--sp-2) 0;">
<button type="button" class="gx-btn gx-btn--tonal" style="min-height:34px; padding:0 var(--sp-3);" @click="setGroup(group, true)">Select All</button>
<button type="button" class="gx-btn gx-btn--text" style="min-height:34px;" @click="setGroup(group, false)">Clear</button>
</div>
<div style="display:grid; gap:2px; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));">
<label v-for="region in group.visibleRegions" :key="region.token" class="gx-row" style="border:none; border-radius:var(--radius-md); min-height:0; padding:6px 8px; gap:8px; cursor:pointer;">
<input type="checkbox" :checked="selectedDraft.includes(region.token)" @change="toggleToken(region.token, $event.target.checked)" style="accent-color:var(--primary); width:16px; height:16px; flex:none;" />
<span style="flex:1; min-width:0; overflow-wrap:anywhere;">{{ region.label }}</span>
<span style="font-size:var(--fs-xs); color:var(--text-muted); flex:none;">{{ region.code }}</span>
</label>
</div>
</div>
</transition>
</div>
</div>
</section>
<GxNotice v-if="error" tone="danger" :text="error" />
</div>
`,
}
@@ -0,0 +1,233 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "./GalaxyModal.js"
import { GxNotice } from "./GxNotice.js"
const DEFAULT_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.embaucha.galaxynav&hl=en-US&ah=9FldHJ99kxL8oNbSlO5F4sQqwC4"
const KEY_META = {
amap1: { body: "amap1", prefix: "", min: 39, title: "AMap / Gaode 1" },
amap2: { body: "amap2", prefix: "", min: 39, title: "AMap / Gaode 2" },
public: { body: "public", prefix: "pk.", min: 80, title: "Public Mapbox" },
secret: { body: "secret", prefix: "sk.", min: 80, title: "Secret Mapbox" },
}
const KINDS = {
amap: ["amap1", "amap2"],
mapbox: ["public", "secret"],
}
function maskKey(raw) {
if (!raw) return ""
const prefix = ["pk.", "sk."].find((p) => raw.startsWith(p)) || ""
return prefix + "x".repeat(raw.length - prefix.length)
}
function prefixed(raw, prefix) {
return raw.startsWith(prefix) ? raw : prefix + raw
}
export const NavigationKeysPanel = {
name: "NavigationKeysPanel",
components: { GxNotice },
data() {
return {
loading: true,
error: "",
meta: KEY_META,
amapKinds: KINDS.amap,
mapboxKinds: KINDS.mapbox,
appUrl: DEFAULT_PLAY_STORE_URL,
cookieName: "galaxy_session",
paired: false,
sessionToken: "",
sessionVisible: false,
keys: { amap1: "", amap2: "", public: "", secret: "" },
saved: { amap1: false, amap2: false, public: false, secret: false },
editing: { amap1: false, amap2: false, public: false, secret: false },
}
},
async mounted() {
try {
const nav = await api.getNavigation()
if (nav && typeof nav === "object") {
const map = { amap1: nav.amap1Key, amap2: nav.amap2Key, public: nav.mapboxPublic, secret: nav.mapboxSecret }
for (const kind of Object.keys(KEY_META)) {
const raw = map[kind] || ""
this.keys[kind] = raw
this.saved[kind] = !!raw
this.editing[kind] = false
}
}
await this.loadSession()
} catch (e) {
this.error = e?.message || "Failed to load keys..."
} finally {
this.loading = false
}
},
methods: {
async loadSession() {
try {
const d = await api.getGalaxySession()
this.appUrl = d?.appUrl || DEFAULT_PLAY_STORE_URL
this.cookieName = d?.cookieName || "galaxy_session"
this.paired = !!d?.paired
this.sessionToken = d?.sessionToken || ""
this.sessionVisible = false
} catch (e) {
showSnackbar(e?.message || "Failed to load Galaxy session...", "error")
}
},
display(kind) {
if (this.saved[kind] && !this.editing[kind]) return maskKey(this.keys[kind])
return this.keys[kind] || ""
},
beginEdit(kind) {
if (this.saved[kind] && !this.editing[kind]) {
this.editing[kind] = true
this.saved[kind] = false
this.keys[kind] = ""
}
},
inputKey(kind, value) {
this.keys[kind] = value
},
canSave(kind) {
const meta = KEY_META[kind]
const value = (this.keys[kind] || "").trim()
if (!value || this.saved[kind]) return false
return prefixed(value, meta.prefix).length >= meta.min
},
async saveKey(kind) {
const meta = KEY_META[kind]
const value = prefixed((this.keys[kind] || "").trim(), meta.prefix)
if (!value || value.length < meta.min) {
showSnackbar(`${meta.title} key is invalid or too short...`, "error")
return
}
try {
const payload = await api.setNavigationKey({ [meta.body]: value })
this.keys[kind] = value
this.saved[kind] = true
this.editing[kind] = false
showSnackbar(payload?.message || "Saved!")
} catch (e) {
showSnackbar(e?.data?.error || e?.message || "Save failed...", "error")
}
},
async removeKey(kind) {
const meta = KEY_META[kind]
const ok = await GalaxyConfirm({
title: "Confirm Delete",
message: `Are you sure you want to delete your <strong>${meta.title}</strong> key?`,
confirmLabel: "Yes, Delete",
danger: true,
})
if (!ok) return
try {
const payload = await api.deleteNavigationKey(kind)
this.keys[kind] = ""
this.saved[kind] = false
this.editing[kind] = false
showSnackbar(payload?.message || "Deleted!")
} catch (e) {
showSnackbar(e?.message || "Delete failed...", "error")
}
},
async copyToken() {
const text = this.sessionToken
if (!text) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
showSnackbar("Session token copied!")
return
}
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand("copy")
textarea.remove()
if (!ok) throw new Error("Copy failed")
showSnackbar("Session token copied!")
} catch (e) {
showSnackbar("Copy failed...", "error")
}
},
inputLabel(kind) {
return kind[0].toUpperCase() + kind.slice(1).replace(/[0-9]/, (d) => " " + d)
},
},
template: `
<div style="display:grid; gap:12px;">
<div v-if="loading" class="gx-loading">Loading keys...</div>
<template v-else>
<GxNotice v-if="error" tone="danger" :text="error" />
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-signpost"></i>
<span class="gx-section__title">AMap / Gaode Keys</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<p style="margin:0; color:var(--text-muted);">AMap is the Gaode provider, not Google Maps.</p>
<div v-for="kind in amapKinds" :key="kind">
<label class="gx-row__label" style="font-size:var(--fs-xs);">{{ inputLabel(kind) }} Key</label>
<div style="display:flex; gap:8px;">
<input class="gx-field" style="flex:1;" :value="display(kind)" @focus="beginEdit(kind)" @input="inputKey(kind, $event.target.value)" :placeholder="meta[kind].prefix + 'xxxxxx...'" autocomplete="off" />
<button type="button" class="gx-icon-btn" :title="saved[kind] ? 'Delete key' : 'Save key'" :style="saved[kind] ? 'color:var(--error);' : ''" :disabled="!saved[kind] && !canSave(kind)" @click="saved[kind] ? removeKey(kind) : saveKey(kind)">
<i class="bi" :class="saved[kind] ? 'bi-trash' : 'bi-save'"></i>
</button>
</div>
</div>
</div>
</section>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-map"></i>
<span class="gx-section__title">Mapbox Keys</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<p style="margin:0; color:var(--text-muted);">Public and secret Mapbox tokens used by the navigation app.</p>
<div v-for="kind in mapboxKinds" :key="kind">
<label class="gx-row__label" style="font-size:var(--fs-xs);">{{ inputLabel(kind) }} Key</label>
<div style="display:flex; gap:8px;">
<input class="gx-field" style="flex:1;" :value="display(kind)" @focus="beginEdit(kind)" @input="inputKey(kind, $event.target.value)" :placeholder="meta[kind].prefix + 'xxxxxx...'" autocomplete="off" />
<button type="button" class="gx-icon-btn" :title="saved[kind] ? 'Delete key' : 'Save key'" :style="saved[kind] ? 'color:var(--error);' : ''" :disabled="!saved[kind] && !canSave(kind)" @click="saved[kind] ? removeKey(kind) : saveKey(kind)">
<i class="bi" :class="saved[kind] ? 'bi-trash' : 'bi-save'"></i>
</button>
</div>
</div>
</div>
</section>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-phone"></i>
<span class="gx-section__title">App Keys</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<a class="gx-btn gx-btn--tonal" :href="appUrl" target="_blank" rel="noopener noreferrer" style="justify-self:start;">
<i class="bi bi-google-play"></i> Install The App
</a>
<label class="gx-row__label" style="font-size:var(--fs-xs);">Cookie Name</label>
<input class="gx-field gx-field--full" readonly :value="cookieName" />
<label class="gx-row__label" style="font-size:var(--fs-xs);">Session Token</label>
<div style="display:flex; gap:8px;">
<input class="gx-field" style="flex:1;" readonly :type="sessionVisible ? 'text' : 'password'" :placeholder="paired ? 'Session token unavailable...' : 'Pair Galaxy to create a session token...'" :value="sessionToken" />
<button type="button" class="gx-icon-btn" :title="sessionVisible ? 'Hide session token' : 'Show session token'" :disabled="!sessionToken" @click="sessionVisible = !sessionVisible">
<i class="bi" :class="sessionVisible ? 'bi-eye-slash' : 'bi-eye'"></i>
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!sessionToken" @click="copyToken"><i class="bi bi-copy"></i> Copy</button>
</div>
</div>
</section>
</template>
</div>
`,
}
@@ -0,0 +1,131 @@
export const FIRESTAR_HOST = "galaxy.firestar.link"
export function isFirestarOrigin() {
return window.location.hostname === FIRESTAR_HOST
}
function isIos() {
const ua = window.navigator.userAgent
return /iPad|iPhone|iPod/.test(ua) && !/CriOS|FxiOS|OPiOS|EdgiOS/.test(ua)
}
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches || !!window.navigator.standalone
}
export const PwaInstallSection = {
name: "PwaInstallSection",
props: {
// The device's paired Galaxy link (https://galaxy.firestar.link/<slug>).
galaxyUrl: { type: String, default: "" },
},
data() {
return {
installed: isStandalone(),
isIos: isIos(),
onFirestar: isFirestarOrigin(),
deferredPrompt: null,
}
},
computed: {
// The stable URL users should install from. Prefer the paired Galaxy link;
// fall back to the current page when we are already on the Firestar host.
installUrl() {
if (this.galaxyUrl) return this.galaxyUrl
if (this.onFirestar) return window.location.origin
return ""
},
canInstall() { return !!this.deferredPrompt && !this.installed },
showManual() { return this.isIos && !this.installed && !this.deferredPrompt },
onLocal() { return !this.onFirestar && !this.installUrl },
},
methods: {
capturePrompt(e) {
e.preventDefault()
this.deferredPrompt = e
},
async install() {
const prompt = this.deferredPrompt
if (!prompt) return
prompt.prompt()
try { await prompt.userChoice } catch (e) { /* user cancelled */ }
this.deferredPrompt = null
this.installed = true
},
installHost() {
try { return new URL(this.installUrl).host } catch (e) { return this.installUrl }
},
},
mounted() {
window.addEventListener("beforeinstallprompt", this.capturePrompt)
},
beforeUnmount() {
window.removeEventListener("beforeinstallprompt", this.capturePrompt)
},
template: `
<section v-if="!installed" class="gx-card gx-install">
<div class="gx-section__header">
<i class="bi bi-phone-fill"></i>
<span class="gx-section__title">Install Galaxy as an App</span>
</div>
<div class="gx-install__body">
<p class="gx-install__lead">
Galaxy is designed to be used as a <strong>Progressive Web App</strong>, giving you a full-screen app experience with no app store download required.
</p>
<ul class="gx-install__benefits">
<li><i class="bi bi-hdd"></i><span>Uses a tiny fraction of the storage of a native app.</span></li>
<li><i class="bi bi-lightning-charge"></i><span>Opens instantly in its own window, giving a native-app like feel.</span></li>
<li><i class="bi bi-cloud-download"></i><span>No need to manually update the app.</span></li>
<li><i class="bi bi-wifi"></i><span>Requires an active network connection at all times, just like the browser version.</span></li>
</ul>
<div v-if="onLocal" class="gx-install__hint">
<i class="bi bi-info-circle-fill"></i>
<span>To install Galaxy, open it from a paired Galaxy link (not this device address, which can
change between networks). If your device is paired, open the Galaxy link shown above and
come back to this page for further steps.</span>
</div>
<div v-if="onFirestar && canInstall" class="gx-install__actions">
<p class="gx-install__hint" style="margin:0 0 var(--sp-3);">
<i class="bi bi-check-circle-fill"></i>
<span>This page is ready. Add Galaxy to your home screen to launch it like a native app.</span>
</p>
<button type="button" class="gx-btn gx-btn--block" @click="install">
<i class="bi bi-download"></i> Install Galaxy
</button>
</div>
<div v-else-if="onFirestar && showManual" class="gx-install__manual">
<p class="gx-install__hint"><i class="bi bi-share-fill"></i><span>Galaxy is already available
on this page. To install it on your iPhone or iPad:</span></p>
<ol class="gx-install__steps">
<li>Tap the <strong>Share</strong> button in the browser toolbar.</li>
<li>Scroll down and tap <strong>Add to Home Screen</strong>.</li>
<li>Tap <strong>Add</strong>, and Galaxy will appear on your home screen.</li>
</ol>
</div>
<div v-else-if="onFirestar && !isIos" class="gx-install__actions">
<p class="gx-install__hint" style="margin:0 0 var(--sp-3);">
<i class="bi bi-wifi"></i>
<span>Checking whether this page can be installed... If the button doesn't appear, your
browser may already have Galaxy installed or may need a stable connection.</span>
</p>
</div>
<div v-if="installUrl && !onFirestar" class="gx-install__actions">
<p class="gx-install__hint" style="margin:0 0 var(--sp-3);">
<i class="bi bi-box-arrow-up-right"></i>
<span>Open your Galaxy link ({{ installHost() }}) and use the install option there. It
stays the same even if this device's address changes.</span>
</p>
<a class="gx-btn gx-btn--block" :href="installUrl" target="_blank" rel="noopener" style="text-decoration:none;">
<i class="bi bi-box-arrow-up-right"></i> Open Galaxy to Install
</a>
</div>
</div>
</section>
`,
}
@@ -0,0 +1,121 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GxNotice } from "./GxNotice.js"
export const SpeedLimitsPanel = {
name: "SpeedLimitsPanel",
components: { GxNotice },
data() {
return {
loading: true,
canProcessNow: false,
processing: false,
reason: "",
status: "Idle",
submitting: false,
vision: {
bookmarkCount: 0, confidence: 0, debugSession: "", displaySpeed: 0,
enabled: false, lastEvent: "", speedUnit: "mph", status: "Disabled", stream: "",
},
}
},
created() {
this.poll = usePolling(() => this.load(), { interval: 3000 })
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
methods: {
async load() {
try {
const d = await api.getSpeedLimitsStatus()
this.canProcessNow = Boolean(d?.canProcessNow)
this.processing = Boolean(d?.processing)
this.reason = d?.reason || ""
this.status = d?.status || "Idle"
this.vision = {
bookmarkCount: Number(d?.visionBookmarkCount || 0),
confidence: Number(d?.visionConfidence || 0),
debugSession: d?.visionDebugSession || "",
displaySpeed: Number(d?.visionDisplaySpeed || 0),
enabled: Boolean(d?.visionEnabled),
lastEvent: d?.visionLastEvent || "",
speedUnit: d?.visionSpeedUnit || "mph",
status: d?.visionStatus || (d?.visionEnabled ? "Idle" : "Disabled"),
stream: d?.visionStream || "",
}
} catch (e) {
this.status = "Unavailable"
this.vision.status = "Unavailable"
this.canProcessNow = false
this.processing = false
} finally {
this.loading = false
}
},
download() {
const a = document.createElement("a")
a.href = "/api/speed_limits"
a.download = "speed_limits.json"
a.click()
showSnackbar("Download started...")
},
async processNow() {
if (this.submitting || this.processing || !this.canProcessNow) return
this.submitting = true
try {
const payload = await api.processSpeedLimits()
showSnackbar(payload?.message || "Speed limit processing started.")
await this.load()
} catch (e) {
showSnackbar(e?.message || "Failed to start speed limit processing.", "error")
} finally {
this.submitting = false
}
},
visionSuffix() {
const v = this.vision
let out = `${v.status}`
if (v.stream) out += ` on ${v.stream}`
if (v.displaySpeed > 0) {
out += ` (${v.displaySpeed} ${v.speedUnit}`
if (v.confidence > 0) out += `, ${Math.round(v.confidence * 100)}%`
out += ")"
}
return out
},
},
template: `
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-sign-stop-fill"></i>
<span class="gx-section__title">Download Speed Limits</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<p style="margin:0; color:var(--text-muted);">
Enable "Speed Limit Filler" on the device, drive to collect data, then process and download it here when parked.
</p>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Processor Status</span>
<span class="gx-row__value">{{ loading ? 'Checking...' : status }}</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Vision Detector</span>
<span class="gx-row__value">{{ loading ? 'Checking...' : visionSuffix() }}</span>
</div>
<div v-if="!loading && vision.enabled" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Vision Debug</span>
<span class="gx-row__value">{{ vision.debugSession || 'No active session' }}<template v-if="vision.bookmarkCount">, {{ vision.bookmarkCount }} bookmark{{ vision.bookmarkCount === 1 ? '' : 's' }}</template></span>
</div>
<div v-if="!loading && vision.enabled && vision.lastEvent" class="gx-row__desc">{{ vision.lastEvent }}</div>
<GxNotice v-if="!loading && reason && reason !== status" :text="reason" style="margin:0;" />
<div style="display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; align-items:center;">
<a class="gx-btn" :href="'/api/speed_limits'" download="speed_limits.json" @click="showSnackbar('Download started...')"><i class="bi bi-download"></i> Download</a>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="submitting || processing || !canProcessNow" @click="processNow">
{{ processing || submitting ? 'Processing...' : 'Process Now' }}
</button>
<a class="gx-btn gx-btn--text" href="https://nerf.077769.xyz/" target="_blank" rel="noopener noreferrer">Submit speed limits here</a>
</div>
</div>
</section>
`,
}
@@ -0,0 +1,237 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "./GalaxyModal.js"
import { GxNotice } from "./GxNotice.js"
function formatValue(value) {
if (typeof value === "boolean") return value ? "On" : "Off"
if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)))
if (value === null || value === undefined) return "n/a"
const text = String(value).trim()
return text || "(empty)"
}
function formatLearnedValue(value) {
if (value === null || value === undefined) return ""
const text = String(value).trim()
return text ? formatValue(value) : ""
}
function valuesMatch(left, right) {
if (left === right) return true
if ((left === null || left === undefined) && (right === null || right === undefined)) return true
if (typeof left === "number" && typeof right === "number") return Math.abs(left - right) < 1e-9
const lt = String(left ?? "").trim()
const rt = String(right ?? "").trim()
if (!lt && !rt) return true
const ln = Number(lt)
const rn = Number(rt)
if (lt !== "" && rt !== "" && Number.isFinite(ln) && Number.isFinite(rn)) return Math.abs(ln - rn) < 1e-9
return lt === rt
}
export const TroubleshootPanel = {
name: "TroubleshootPanel",
components: { GxNotice },
data() {
return {
loading: true,
refreshing: false,
busySection: "",
error: "",
onlyNonDefault: false,
vehicleStatus: { available: false, summary: "", summarySeverity: "neutral", items: [] },
snapshot: [],
sections: [],
isOnroad: false,
}
},
computed: {
countNonDefault() {
return this.sections.reduce((count, s) => count + (Array.isArray(s.items) ? s.items.filter((i) => !valuesMatch(i?.value, i?.defaultValue)).length : 0), 0)
},
visibleSections() {
if (!this.onlyNonDefault) return this.sections
return this.sections.filter((s) => (Array.isArray(s.items) ? s.items.filter((i) => !valuesMatch(i?.value, i?.defaultValue)).length : 0) > 0)
},
},
mounted() { this.load() },
methods: {
formatValue,
formatLearnedValue,
isChanged(item) { return !valuesMatch(item?.value, item?.defaultValue) },
itemsVisible(section) {
const items = Array.isArray(section?.items) ? section.items : []
return this.onlyNonDefault ? items.filter((i) => !valuesMatch(i?.value, i?.defaultValue)) : items
},
severity(sev) {
const s = String(sev || "neutral").toLowerCase()
if (s === "ok" || s === "live") return "var(--success)"
if (s === "warn" || s === "warning") return "var(--warning)"
if (s === "error" || s === "critical") return "var(--error)"
return "var(--text-muted)"
},
statusText() {
const s = String(this.vehicleStatus?.summarySeverity || "neutral").toLowerCase()
const badge = this.vehicleStatus?.available ? { label: "Live", color: "var(--success)" } : { label: "Unavailable", color: s === "error" ? "var(--error)" : "var(--text-muted)" }
return badge
},
async load(showToast = false) {
if (this.refreshing) return
this.refreshing = true
if (!this.sections.length) this.loading = true
this.error = ""
try {
const p = await api.getTroubleshoot()
if (!p) throw new Error("Failed to load troubleshoot data")
this.snapshot = Array.isArray(p.snapshot) ? p.snapshot : []
this.sections = Array.isArray(p.sections) ? p.sections : []
this.vehicleStatus = {
available: !!p.vehicleStatus?.available,
summary: String(p.vehicleStatus?.summary || ""),
summarySeverity: String(p.vehicleStatus?.summarySeverity || "neutral"),
items: Array.isArray(p.vehicleStatus?.items) ? p.vehicleStatus.items : [],
}
this.isOnroad = !!p.isOnroad
if (showToast) showSnackbar("Troubleshoot data refreshed.")
} catch (e) {
this.error = e?.message || "Failed to load troubleshoot data"
if (showToast) showSnackbar(this.error, "error")
} finally {
this.refreshing = false
this.loading = false
}
},
async resetSection(section) {
const id = String(section?.id || "")
if (!id || this.busySection) return
const title = String(section?.title || "this section")
if (!(await GalaxyConfirm({ title: "Reset to defaults?", message: `Reset ${title} to defaults?`, confirmLabel: "Reset", danger: true }))) return
this.busySection = id
try {
const p = await api.resetTroubleshootSection(id)
showSnackbar(p?.message || `${title} reset.`)
await this.load(false)
} catch (e) {
showSnackbar(e?.message || "Failed to reset section.", "error")
} finally {
this.busySection = ""
}
},
async copyReport() {
const lines = []
lines.push("StarPilot Troubleshoot Report")
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(`Onroad: ${this.isOnroad ? "Yes" : "No"}`)
lines.push(`Only non-default values: ${this.onlyNonDefault ? "Yes" : "No"}`)
if (this.vehicleStatus?.summary) {
lines.push("")
lines.push("Vehicle Fault Status")
lines.push(`Summary: ${this.vehicleStatus.summary}`)
for (const item of this.vehicleStatus.items || []) lines.push(`- ${item.label}: ${this.formatValue(item.value)}`)
}
lines.push("")
lines.push("Snapshot")
for (const item of this.snapshot) lines.push(`- ${item.label}: ${this.formatValue(item.value)}`)
for (const section of this.visibleSections) {
lines.push("")
lines.push(section.title)
for (const item of this.itemsVisible(section)) {
const learned = this.formatLearnedValue(item.learnedValue)
lines.push(`- ${item.label}: ${this.formatValue(item.value)} (default: ${this.formatValue(item.defaultValue)}${learned ? `, learned: ${learned}` : ""})`)
}
}
const text = lines.join("\n")
try {
if (navigator.clipboard?.writeText) await 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("Troubleshoot report copied to clipboard.")
} catch (e) {
showSnackbar("Failed to copy report.", "error")
}
},
},
template: `
<div>
<section class="gx-card">
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<p style="margin:0; color:var(--text-muted);">Quick diagnostics snapshot for weird behavior reports and copy-ready debug logs.</p>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="refreshing" @click="load(true)"><i v-if="refreshing" class="bi bi-arrow-repeat gx-spin"></i><i v-else class="bi bi-arrow-clockwise"></i> {{ refreshing ? 'Refreshing...' : 'Refresh' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="copyReport"><i class="bi bi-clipboard"></i> Copy to Clipboard</button>
</div>
<div class="gx-row" style="border:none; border-radius:0; min-height:0;">
<div class="gx-row__info"><span class="gx-row__label" style="font-size:var(--fs-base); font-weight:var(--fw-medium);">Only non-default values</span></div>
<label class="gx-switch">
<input type="checkbox" v-model="onlyNonDefault" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
<GxNotice v-if="error" tone="danger" :text="error" style="margin:var(--sp-2) 0 0;" />
<div class="gx-row__desc"><strong>Onroad:</strong> {{ isOnroad ? 'Yes' : 'No' }}</div>
<div class="gx-row__desc"><strong>Changed Settings:</strong> {{ countNonDefault }}</div>
</div>
</section>
<div v-if="loading" class="gx-loading">Loading troubleshoot data...</div>
<template v-else>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-car-front"></i>
<span class="gx-section__title">Vehicle Fault Status</span>
<span class="gx-chip" :style="'background:' + statusText().color + (statusText().label==='Live' ? ';color:var(--black);' : '')">{{ statusText().label }}</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<p style="margin:0;">{{ vehicleStatus?.summary || 'Vehicle fault status unavailable.' }}</p>
<div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:8px;">
<div v-for="item in vehicleStatus?.items || []" :key="item.label" class="gx-row" style="border:none; background:var(--surface); border-radius:var(--radius-md); padding:8px 10px;">
<div class="gx-row__info"><span class="gx-row__label">{{ item.label }}</span></div>
<span class="gx-row__value" :style="'color:' + severity(item.severity)">{{ formatValue(item.value) }}</span>
</div>
</div>
</div>
</section>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-camera"></i>
<span class="gx-section__title">Snapshot</span>
</div>
<div v-if="!snapshot.length" class="gx-empty">No snapshot data.</div>
<div v-else style="display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:8px; padding:0 var(--sp-3) var(--sp-3);">
<div v-for="item in snapshot" :key="item.label" class="gx-row" style="border:none; background:var(--surface); border-radius:var(--radius-md); padding:8px 10px;">
<div class="gx-row__info"><span class="gx-row__label">{{ item.label }}</span></div>
<span class="gx-row__value">{{ formatValue(item.value) }}</span>
</div>
</div>
</section>
<section v-for="section in visibleSections" :key="section.id" class="gx-card">
<div class="gx-section__header">
<i class="bi bi-sliders"></i>
<span class="gx-section__title">{{ section.title }}</span>
<button v-if="section.resettable" type="button" class="gx-btn gx-btn--text" style="color:var(--error);" :disabled="busySection === section.id" @click="resetSection(section)">{{ busySection === section.id ? 'Resetting...' : 'Reset to Default' }}</button>
</div>
<div v-if="!itemsVisible(section).length" class="gx-empty">No settings are currently different from their defaults.</div>
<div v-else style="display:grid; gap:8px; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); padding:0 var(--sp-3) var(--sp-3);">
<div v-for="item in itemsVisible(section)" :key="item.label" class="gx-row"
style="border:none; background:var(--surface); border-radius:var(--radius-md); margin:0; padding:10px 12px; flex-direction:column; align-items:stretch; gap:8px;">
<div style="display:flex; align-items:center; gap:6px; min-width:0;">
<span class="gx-row__label" style="font-size:var(--fs-sm); overflow-wrap:anywhere;">{{ item.label }}</span>
<span v-if="isChanged(item)" class="gx-chip" style="background:var(--warning);color:var(--black); flex:none;">Changed</span>
</div>
<div style="display:grid; gap:2px; font-size:var(--fs-sm);">
<span><span style="color:var(--text-muted);">current </span><b style="overflow-wrap:anywhere;">{{ formatValue(item.value) }}</b></span>
<span style="color:var(--text-muted); overflow-wrap:anywhere;">default {{ formatValue(item.defaultValue) }}</span>
<span v-if="section.hasLearnedColumn && formatLearnedValue(item.learnedValue)" style="color:var(--text-muted); overflow-wrap:anywhere;">learned {{ formatLearnedValue(item.learnedValue) }}</span>
</div>
</div>
</div>
</section>
<section v-if="onlyNonDefault && countNonDefault === 0" class="gx-card"><div class="gx-empty">No settings are currently different from their defaults.</div></section>
</template>
</div>
`,
}
@@ -1,10 +1,12 @@
import { api } from "../api.js"
import { usePolling } from "../composables.js"
import { GxNotice } from "./GxNotice.js"
const FAVORITE_SLOT_COUNT = 3
export const WheelControls = {
name: "WheelControls",
components: { GxNotice },
data() {
return {
loading: true, busy: "", available: false, offroad: false, learning: false,
@@ -85,16 +87,17 @@ export const WheelControls = {
const seconds = Math.max(0, Math.ceil(this.remainingSeconds))
return seconds > 0 ? `Listening (${seconds}s)` : "Listening..."
},
deleteMapping(m) { this.request("delete", { id: m.id }) },
},
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>
<GxNotice v-if="!offroad" text="Mappings can only be changed while offroad. Mapped buttons continue working onroad." style="margin:0 0 var(--sp-2);" />
<GxNotice v-if="error" tone="danger" :text="error" style="margin:0 0 var(--sp-2);" />
<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>
<button type="button" class="gx-btn gx-btn--danger" :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>
@@ -125,14 +128,14 @@ export const WheelControls = {
<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>
<span v-if="mappingsOf(i).length" class="gx-chip gx-chip--dev" v-for="m in mappingsOf(i)" :key="m.id || m.event_code" style="display:inline-flex; align-items:center; gap:4px;">{{ m.event_name || ('Button ' + m.event_code) }}<button type="button" class="gx-chip-x" :disabled="disabled() || testing" title="Remove mapping" @click="deleteMapping(m)"><i class="bi bi-x"></i></button></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 style="display:grid; gap:8px; grid-template-columns:repeat(auto-fill,minmax(280px,1fr));">
<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">
@@ -154,8 +157,8 @@ export const WheelControls = {
: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 v-if="mappingsOf(actionSlotIndex(i)).length" style="display:flex; flex-wrap:wrap; gap:4px;">
<span v-for="m in mappingsOf(actionSlotIndex(i))" :key="m.id || m.event_code" class="gx-chip gx-chip--dev" style="display:inline-flex; align-items:center; gap:4px;">{{ m.event_name || ('Button ' + m.event_code) }}<button type="button" class="gx-chip-x" :disabled="disabled() || testing" title="Remove mapping" @click="deleteMapping(m)"><i class="bi bi-x"></i></button></span>
</div>
</div>
</div>
@@ -56,23 +56,24 @@ export function matchesSettingValueCondition(param, values) {
return allowedValues.some((value) => toSelectValue(value) === currentValue)
}
// Any structural reason a param must not be shown (regardless of dev mode).
function isHiddenByConditions(section, param, values) {
if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return true
if (param.requires_capability && !values[param.requires_capability]) return true
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return true
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return true
return false
}
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 (isHiddenByConditions(section, param, values)) 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
return !isHiddenByConditions(section, param, values)
}
export function countAdvancedHiddenByDeveloperMode(layout, values) {
@@ -72,12 +72,7 @@ function applyRoute(route, { scrollToTop = false } = {}) {
export function navigate(target) {
const { path } = parseHash(target)
if (path === currentPath()) {
applyRoute(target)
window.location.hash = toHash(target)
return
}
pushIfNew(path)
if (path !== currentPath()) pushIfNew(path)
applyRoute(target)
window.location.hash = toHash(target)
}
@@ -96,7 +91,7 @@ export function goBack() {
window.location.hash = prev
}
const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed"])
const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed", "/manage_doors", "/galaxy", "/manage_tsk", "/sentry", "/manage_models", "/plots", "/testing_ground", "/theme_maker", "/cameras"])
export function toolHref(link) {
const path = link.split("?")[0]
@@ -107,13 +102,8 @@ export function toolHref(link) {
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
applyRoute(route)
pushIfNew(route)
if (pathChanged) window.scrollTo(0, 0)
}
window.addEventListener("hashchange", apply)
store.history = ["/"]
@@ -0,0 +1,32 @@
import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { useTabRouting } from "../composables.js"
import { Vasm } from "./Vasm.js"
import { Pip } from "./Pip.js"
const TABS = {
vasm: "V-ASM Spot Monitor",
pip: "PiP Side Camera",
}
export const Cameras = {
name: "Cameras",
components: { Vasm, Pip, GalaxyTabs },
setup() {
return useTabRouting("/cameras", { vasm: "vasm", pip: "pip" })
},
data() { return { TABS } },
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Cameras & Monitoring</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'vasm'">
<Vasm :embedded="true" />
</template>
<template v-else>
<Pip :embedded="true" />
</template>
</div>
`,
}
@@ -0,0 +1,35 @@
import { api, showSnackbar } from "../api.js"
export const Doors = {
name: "Doors",
data() { return { busy: "" } },
methods: {
async act(action) {
if (this.busy) return
this.busy = action
try {
const payload = await api.postAction(`/api/doors/${action}`)
showSnackbar(payload?.message || (action === "lock" ? "Doors locked!" : "Doors unlocked!"))
} catch (e) {
showSnackbar(e?.message || (action === "lock" ? "Failed to lock doors." : "Failed to unlock doors."), "error")
} finally {
this.busy = ""
}
},
},
template: `
<div>
<h2 style="margin-top:0;">Lock/Unlock Doors</h2>
<section class="gx-card">
<div style="padding: var(--sp-4); display:grid; gap:12px; text-align:center;">
<i class="bi bi-car-front" style="font-size:2rem; color:var(--primary);"></i>
<p style="margin:0; color:var(--text-muted);">Remotely lock or unlock your car doors using the buttons below.</p>
<div style="display:flex; gap:12px; justify-content:center; flex-wrap:wrap;">
<button type="button" class="gx-btn" :disabled="!!busy" @click="act('lock')"><i class="bi bi-lock-fill"></i> Lock Doors</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="act('unlock')"><i class="bi bi-unlock-fill"></i> Unlock Doors</button>
</div>
</div>
</section>
</div>
`,
}
@@ -0,0 +1,118 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { PwaInstallSection, isFirestarOrigin } from "../components/PwaInstallSection.js"
const isTunnel = () => isFirestarOrigin()
export const Galaxy = {
name: "Galaxy",
components: { PwaInstallSection },
data() {
return {
loading: true,
paired: false,
url: "",
password: "",
submitting: false,
}
},
async mounted() {
if (this.isTunnel) return
try {
const data = await api.getGalaxyStatus()
this.paired = !!data?.paired
this.url = data?.url || ""
} catch (e) {
showSnackbar("Failed to check pairing status.", "error")
} finally {
this.loading = false
}
},
computed: { isTunnel: () => isTunnel() },
methods: {
async pair() {
const pw = this.password.trim()
if (pw.length < 6) { showSnackbar("Password must be at least 6 characters."); return }
this.submitting = true
try {
const data = await api.galaxyPair(pw)
this.paired = true
this.url = data?.url || ""
this.password = ""
showSnackbar(data?.message || "Paired!")
} catch (e) {
showSnackbar(e?.message || "Network error — is the device reachable?", "error")
} finally {
this.submitting = false
}
},
async unpair() {
if (!(await GalaxyConfirm({
title: "Confirm Unpair",
message: "Are you sure you want to unpair from Galaxy? You will lose remote access until you pair again.",
confirmLabel: "Unpair",
danger: true,
}))) return
this.submitting = true
try {
const data = await api.galaxyUnpair()
this.paired = false
this.url = ""
showSnackbar(data?.message || "Unpaired!")
} catch (e) {
showSnackbar(e?.message || "Network error — is the device reachable?", "error")
} finally {
this.submitting = false
}
},
},
template: `
<div>
<h2 style="margin-top:0;">Galaxy & App Install</h2>
<template v-if="isTunnel">
<section class="gx-card">
<div class="gx-alert gx-alert--warn" style="border:none;margin:0;">
<i class="bi bi-satellite gx-alert__icon"></i>
<div class="gx-alert__body">
<strong>Galaxy Pairing Unavailable via Galaxy</strong>
<span>Galaxy pairing requires a direct connection. Connect to your device's local network to use this feature.</span>
</div>
</div>
</section>
</template>
<div v-else-if="loading" class="gx-loading">Checking pairing status…</div>
<section v-else class="gx-card">
<template v-if="paired">
<div style="padding: var(--sp-4); display:grid; gap:12px;">
<span class="gx-chip" style="background:var(--success); color:var(--black); justify-self:start;"><i class="bi bi-check-circle-fill"></i> Paired</span>
<p style="margin:0; color:var(--text-muted);">Your device is paired with Galaxy. Access it remotely at:</p>
<a :href="url" target="_blank" rel="noopener" style="color:var(--primary); word-break:break-all;">{{ url }}</a>
<div>
<button type="button" class="gx-btn gx-btn--danger" :disabled="submitting" @click="unpair">
{{ submitting ? 'Unpairing…' : 'Unpair' }}
</button>
</div>
</div>
</template>
<template v-else>
<div style="padding: var(--sp-4); display:grid; gap:12px;">
<span class="gx-chip gx-chip--lock" style="justify-self:start;">Not Paired</span>
<p style="margin:0; color:var(--text-muted);">Pair your device with Galaxy to access The Galaxy remotely from anywhere. Set a password to secure your connection.</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<input class="gx-field" style="flex:1; min-width:200px;" type="password" v-model="password" placeholder="Password (min 6 characters)" @keydown.enter="pair" />
<button type="button" class="gx-btn" :disabled="submitting || password.trim().length < 6" @click="pair">
{{ submitting ? 'Pairing…' : 'Pair' }}
</button>
</div>
</div>
</template>
</section>
<PwaInstallSection :galaxy-url="url" />
</div>
`,
}
@@ -1,11 +1,567 @@
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
const toNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
const toInt = (v) => Math.round(toNum(v)).toLocaleString("en-US", { maximumFractionDigits: 0 })
const toDec = (v) => toNum(v).toLocaleString("en-US", { maximumFractionDigits: 1 })
const clamp = (v, min = 0, max = 100) => Math.max(min, Math.min(max, toNum(v)))
const pct = (v) => `${Math.round(clamp(v))}%`
const fmtDuration = (seconds) => {
const total = Math.max(0, Math.round(toNum(seconds)))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
}
const fmtBytes = (bytes) => {
const value = Math.max(0, toNum(bytes))
if (value >= 2 ** 30) return `${toDec(value / 2 ** 30)} GB`
if (value >= 2 ** 20) return `${toDec(value / 2 ** 20)} MB`
return `${toInt(value / 1024)} KB`
}
const dateShort = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
const timeShort = (d) => d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const dateTimeShort = (d) => d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
function fmtDriveRange(startValue, endValue) {
if (!startValue) return "No drives yet"
const start = new Date(startValue)
if (Number.isNaN(start.getTime())) return String(startValue)
const end = new Date(endValue)
if (Number.isNaN(end.getTime()) || end <= start) return dateTimeShort(start)
if (start.toDateString() === end.toDateString()) {
return `${dateShort(start)}, ${timeShort(start)}-${timeShort(end)}`
}
return `${dateShort(start)}, ${timeShort(start)}-${dateShort(end)}, ${timeShort(end)}`
}
const driveReady = (drive) => drive?.ignored === true || drive?.attentionKnown !== false
const driveUnit = (drive, fallback = "miles") => drive?.distanceUnit || fallback
const driveSpeedUnit = (drive, unit) => drive?.speedUnit || (unit === "kilometers" ? "kph" : "mph")
const FAVORITE_COLORS = ["#5ec8c8", "#8b6cc5", "#d4a060", "#e05577", "#6cc56e", "#8aa3ff"]
const TOP_MODEL_LIMIT = 3
const WEEK_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
function hasPendingWork(dashboard) {
const analysis = dashboard?.analysis || {}
const pendingRoutes = Math.max(0, Math.round(toNum(analysis.pendingRoutes)))
const recent = Array.isArray(dashboard?.recentDrives) ? dashboard.recentDrives : []
return Boolean(analysis.running) || pendingRoutes > 0 || recent.some((d) => !driveReady(d))
}
const emptyStats = () => ({ drives: 0, distance: 0, hours: 0 })
const emptyDrive = () => ({
date: "", endDate: "", distance: 0, distanceUnit: "", duration: 0,
avgSpeed: 0, speedUnit: "", engagedPercent: 0, model: "Unknown model",
distractedMoments: 0, unresponsiveMoments: 0, ignored: false, attentionKnown: true,
})
export const Home = {
name: "Home",
components: { GalaxyEmbed },
data() {
return {
status: "loading",
error: "",
payload: null,
unit: "miles",
keepRefreshing: false,
togglingKey: "",
}
},
computed: {
dash() { return this.payload?.dashboard || {} },
driveStats() { return this.payload?.driveStats || {} },
software() { return this.payload?.softwareInfo || {} },
device() { return this.dash.device || {} },
week() { return this.dash.week || {} },
storage() { return this.dash.storage || {} },
hero() {
const device = this.device
const online = device.online !== false
return {
status: device.status || "Parked",
text: `${device.status || "Parked"} - ${online ? "device online" : "device offline"}`,
online,
}
},
analysis() {
const analysis = this.dash.analysis || {}
const pendingRoutes = Math.max(0, Math.round(toNum(analysis.pendingRoutes)))
const pendingDrives = (this.dash.recentDrives || []).filter((d) => !driveReady(d)).length
const count = Math.max(pendingRoutes, pendingDrives)
if (!analysis.running && count <= 0) return null
const shown = count || Math.max(1, Math.round(toNum(analysis.batchSize)))
const label = analysis.running
? `Analyzing ${shown} ${shown === 1 ? "drive" : "drives"}`
: `${count} ${count === 1 ? "drive" : "drives"} queued`
return { label }
},
lastDrive() {
const drive = this.dash.lastDrive || emptyDrive()
const ready = driveReady(drive)
const unit = driveUnit(drive, this.unit)
return {
ready,
range: fmtDriveRange(drive.date, drive.endDate),
model: drive.model || "Unknown model",
metrics: [
{ value: ready ? toDec(drive.distance) : "...", label: ready ? unit : "analyzing" },
{ value: fmtDuration(drive.duration), label: "duration" },
{ value: ready ? toInt(drive.avgSpeed) : "...", label: ready ? `${driveSpeedUnit(drive, unit)} avg` : "speed" },
{ value: ready ? pct(drive.engagedPercent) : "...", label: "engaged" },
],
footer: ready
? [
{ icon: "bi-eye", text: `${toInt(drive.distractedMoments)} distracted` },
{ icon: "bi-exclamation-triangle", text: `${toInt(drive.unresponsiveMoments)} unresponsive` },
]
: [{ icon: "bi-hourglass-split", text: "Analyzing stats" }],
}
},
statList() {
const all = { ...emptyStats(), ...(this.driveStats.all || {}) }
const week = { ...emptyStats(), ...(this.driveStats.week || {}) }
const starpilot = { ...emptyStats(), ...(this.driveStats.starpilot || {}) }
const groups = [
{ title: "All time", ...all },
{ title: "Past week", ...week },
{ title: "StarPilot", ...starpilot },
]
return groups.map((g) => ({
title: g.title,
unit: g.unit || this.unit,
metrics: [
{ value: toInt(g.drives), label: "drives" },
{ value: toDec(g.distance), label: g.unit || this.unit },
{ value: toDec(g.hours), label: "hours" },
],
}))
},
weekView() {
const days = Array.isArray(this.week.dailyDistance) && this.week.dailyDistance.length
? this.week.dailyDistance
: WEEK_LABELS.map((label) => ({ label, distance: 0 }))
const maxDistance = Math.max(1, ...days.map((d) => toNum(d.distance)))
return {
engaged: pct(this.week.engagedPercent),
engagedValue: clamp(this.week.engagedPercent),
unit: this.week.distanceUnit || this.unit,
metrics: [
{ value: toDec(this.week.distance), label: this.week.distanceUnit || this.unit },
{ value: toDec(this.week.hours), label: "hours" },
{ value: toInt(this.week.drives), label: "drives" },
],
days: days.map((d) => ({
label: d.label,
distance: toDec(d.distance),
height: Math.max(4, (toNum(d.distance) / maxDistance) * 100),
})),
}
},
recordsList() {
const r = (record) => ({
value: record?.value ?? "0",
detail: record?.detail ?? "",
})
const records = this.dash.records || {}
return [
{ icon: "bi-arrow-right", title: "Longest drive", ...r(records.longestDrive) },
{ icon: "bi-check2-circle", title: "Most-engaged day", ...r(records.mostEngagedDay) },
{ icon: "bi-graph-up-arrow", title: "Best week", ...r(records.bestWeek) },
{ icon: "bi-lightning-charge", title: "Highest streak", ...r(records.highestStreak) },
{ icon: "bi-shield-check", title: "Longest undistracted drive", ...r(records.longestUndistractedDrive) },
{ icon: "bi-stars", title: "Clean-drive streak", ...r(records.cleanDriveStreak) },
]
},
recentList() {
const drives = Array.isArray(this.dash.recentDrives) ? this.dash.recentDrives : []
return drives.map((drive, index) => {
const ignored = drive?.ignored === true
const ready = driveReady(drive)
const unit = driveUnit(drive, this.unit)
const routeNames = Array.isArray(drive?.routeNames) ? drive.routeNames.filter(Boolean) : []
const pending = !ready && !ignored
let distance = ""
if (ignored) distance = "Stats excluded"
else if (pending) distance = "Analyzing stats"
else distance = `${toDec(drive.distance)} ${unit}`
let engaged = ""
let attentionItems = []
if (ignored) {
engaged = "Excluded"
attentionItems = [{ icon: "bi-eye-slash", text: "Ignored from stats" }]
} else if (pending) {
engaged = "Pending"
attentionItems = [{ icon: "bi-hourglass-split", text: "Waiting for full route analysis" }]
} else {
engaged = `${pct(drive.engagedPercent)} engaged`
attentionItems = [
{ icon: "bi-eye", text: `${toInt(drive.distractedMoments)} distracted` },
{ icon: "bi-exclamation-triangle", text: `${toInt(drive.unresponsiveMoments)} unresponsive` },
]
}
return {
key: drive.name || `${drive.date || ""}-${index}`,
range: fmtDriveRange(drive.date, drive.endDate),
model: drive.model || "Unknown model",
ignored,
pending,
routeNames,
distance,
duration: fmtDuration(drive.duration),
attentionItems,
engaged,
engagedValue: ready && !ignored ? clamp(drive.engagedPercent) : 0,
canToggle: routeNames.length > 0,
action: ignored ? "include" : "ignore",
actionIcon: ignored ? "bi-arrow-counterclockwise" : "bi-eye-slash",
actionLabel: ignored ? "Include drive stats" : "Ignore drive stats",
toggleKey: routeNames.join(","),
}
})
},
modelView() {
const models = Array.isArray(this.dash.favoriteModels) ? this.dash.favoriteModels : []
if (models.length === 0) {
return { hasModels: false, style: "", rows: [] }
}
const top = models.slice(0, TOP_MODEL_LIMIT)
const total = top.reduce((sum, m) => sum + Math.max(1, toNum(m.weight)), 0)
let start = 0
const segments = top.map((m, i) => {
const end = start + (Math.max(1, toNum(m.weight)) / total) * 100
const seg = `${FAVORITE_COLORS[i]} ${start}% ${end}%`
start = end
return seg
})
const rows = top.map((m, i) => ({
color: FAVORITE_COLORS[i],
name: m.name,
label: `${toInt(m.drives)} ${toNum(m.drives) === 1 ? "drive" : "drives"} using this model`,
}))
return { hasModels: true, style: `background: conic-gradient(${segments.join(", ")})`, rows }
},
storageView() {
const counts = this.storage.segmentCounts || {}
const summary = this.storage.legacyText
|| `${fmtBytes(this.storage.usedBytes)} used of ${fmtBytes(this.storage.totalBytes)}`
return {
summary,
usedPercent: clamp(this.storage.usedPercent),
rows: [
{ label: "Dashcam footage", value: `${toInt(counts.standard)} segments` },
{ label: "High-resolution footage", value: `${toInt(counts.highResolution)} segments` },
{ label: "Konik footage", value: `${toInt(counts.alternate)} segments` },
{ label: "Free space", value: fmtBytes(this.storage.freeBytes) },
],
}
},
vitalsList() {
const device = this.device
return [
{ label: "Status", value: device.status || "Parked" },
{ label: "LAN IP", value: device.lanIp || "unknown" },
{ label: "Network", value: device.networkName || "No wireless connectivity" },
{ label: "Uptime", value: device.uptimeSeconds == null ? "unknown" : fmtDuration(device.uptimeSeconds) },
{ label: "CPU temp", value: device.cpuTempC == null ? "unknown" : `${toInt(device.cpuTempC)} C` },
{ label: "GPU temp", value: device.gpuTempC == null ? "unknown" : `${toInt(device.gpuTempC)} C` },
]
},
softwareList() {
const info = this.software
const safeGithub = (v) => (String(v || "").trim().startsWith("https://github.com/") ? String(v).trim() : "")
const commitHref = safeGithub(info.changelogUrl) || safeGithub(info.commitUrl)
return [
{ label: "Branch", value: info.branchName, href: "" },
{ label: "Build", value: info.buildEnvironment, href: "" },
{ label: "Commit", value: info.commitHash, href: commitHref },
{ label: "Version date", value: info.versionDate, href: "" },
{ label: "Fork maintainer", value: info.forkMaintainer, href: "" },
{ label: "Update available", value: info.updateAvailable, href: "" },
]
},
},
methods: {
isToggling(routeNames) {
return this.togglingKey === (routeNames || []).join(",")
},
async refresh() {
if (this.status === "loading") return
this.applyLoading()
await this.load()
},
applyLoading() {
this.status = this.payload ? "ready" : "loading"
this.error = ""
},
async load() {
if (this.refreshing) return
this.refreshing = true
if (!this.payload) this.status = "loading"
this.error = ""
try {
const data = await api.getStats()
if (!data) throw new Error("empty stats payload")
const payloadUnit = data?.dashboard?.week?.distanceUnit || data?.driveStats?.all?.unit
this.payload = data
this.unit = payloadUnit || this.unit || "miles"
this.status = "ready"
this.keepRefreshing = hasPendingWork(data?.dashboard || {})
} catch (err) {
if (this.payload) {
showSnackbar("Couldn't refresh dashboard.", "error")
} else {
this.status = "error"
this.error = err?.message || String(err)
}
} finally {
this.refreshing = false
}
},
async toggleDriveStats(drive) {
if (drive.action === "ignore") {
const ok = await GalaxyConfirm({
title: "Ignore this drive's statistics?",
message: "It will no longer affect local weekly totals, records, model usage, engagement, or attention streaks.",
confirmLabel: "Ignore",
})
if (!ok) return
}
this.togglingKey = drive.toggleKey
try {
await api.setDriveStats(drive.action, drive.routeNames)
await this.load()
} catch (err) {
showSnackbar(err?.message || `Unable to ${drive.action} drive statistics.`, "error")
} finally {
this.togglingKey = ""
}
},
},
created() {
this.poll = usePolling(() => this.load(), { interval: 3500, enabled: () => this.keepRefreshing })
this.poll.start()
},
mounted() { this.load() },
beforeUnmount() { this.poll?.destroy() },
template: `
<div class="gx-view">
<GalaxyEmbed src="/classic" title="Home" />
<div class="dh-view">
<template v-if="status === 'loading'">
<div class="gx-loading">Loading dashboard...</div>
</template>
<template v-else-if="status === 'error'">
<section class="gx-card">
<div class="gx-alert gx-alert--warn" style="border:none; margin:0;">
<i class="bi bi-exclamation-triangle-fill gx-alert__icon"></i>
<div class="gx-alert__body">
<strong>Failed to load dashboard</strong>
<span>{{ error }}</span>
</div>
</div>
<div style="padding: var(--sp-4);">
<button type="button" class="gx-btn gx-btn--tonal" @click="refresh"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
</section>
</template>
<template v-else>
<div class="dh-hero">
<div class="dh-hero__info">
<h1 class="dh-title">Dashboard</h1>
<p class="dh-sub">
<span class="gx-status-dot" :class="hero.online ? 'online' : 'offline'"></span>
{{ hero.text }}
</p>
</div>
<button type="button" class="gx-btn gx-btn--tonal dh-refresh" :disabled="status === 'loading'" @click="refresh">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
<div v-if="analysis" class="gx-alert gx-alert--info">
<i class="bi bi-hourglass-split gx-alert__icon"></i>
<div class="gx-alert__body"><span>{{ analysis.label }}</span></div>
</div>
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-controller"></i><span>Last drive</span></div>
<div class="dh-body">
<div class="dh-date">{{ lastDrive.range }}</div>
<div class="dh-metrics">
<div v-for="m in lastDrive.metrics" :key="m.label" class="dh-metric">
<strong>{{ m.value }}</strong><span>{{ m.label }}</span>
</div>
</div>
<div class="dh-tags">
<span class="dh-tag"><i class="bi bi-cpu"></i>{{ lastDrive.model }}</span>
<template v-if="lastDrive.ready">
<span class="dh-tag"><i :class="'bi ' + lastDrive.footer[0].icon"></i>{{ lastDrive.footer[0].text }}</span>
<span class="dh-tag"><i :class="'bi ' + lastDrive.footer[1].icon"></i>{{ lastDrive.footer[1].text }}</span>
</template>
<span v-else class="dh-tag"><i class="bi bi-hourglass-split"></i>Analyzing stats</span>
</div>
</div>
</section>
<div class="dh-label">Your driving</div>
<div class="dh-grid">
<section v-for="stat in statList" :key="stat.title" class="gx-card dh-card dh-stat">
<div class="dh-card__head"><span>{{ stat.title }}</span></div>
<div class="dh-body dh-metrics dh-metrics--3">
<div v-for="m in stat.metrics" :key="m.label" class="dh-metric">
<strong>{{ m.value }}</strong><span>{{ m.label }}</span>
</div>
</div>
</section>
</div>
<div class="dh-grid dh-grid--2">
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-calendar-week"></i><span>This week</span></div>
<div class="dh-body">
<div class="dh-week-top">
<div class="dh-donut" :style="{ '--dh-value': weekView.engagedValue }">
<strong>{{ weekView.engaged }}</strong><span>engaged</span>
</div>
<div class="dh-metrics dh-metrics--3">
<div v-for="m in weekView.metrics" :key="m.label" class="dh-metric">
<strong>{{ m.value }}</strong><span>{{ m.label }}</span>
</div>
</div>
</div>
<div class="dh-bars">
<div v-for="d in weekView.days" :key="d.label" class="dh-bar-day" :title="d.distance">
<div class="dh-bar-day__bar" :style="{ height: d.height + '%' }"></div>
<span>{{ d.label }}</span>
</div>
</div>
</div>
</section>
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-trophy"></i><span>Personal records</span></div>
<div class="dh-list">
<div v-for="rec in recordsList" :key="rec.title" class="dh-record">
<span class="dh-record__icon"><i :class="'bi ' + rec.icon"></i></span>
<div class="dh-record__body">
<span class="dh-record__name">{{ rec.title }}</span>
<strong class="dh-record__value">{{ rec.value }}</strong>
</div>
<small class="dh-record__detail">{{ rec.detail }}</small>
</div>
</div>
</section>
</div>
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-clock-history"></i><span>Recent drives</span></div>
<template v-if="!recentList.length">
<div class="gx-empty">No local drives found yet.</div>
</template>
<div v-else class="dh-list">
<article v-for="drive in recentList" :key="drive.key" class="dh-drive" :class="{ 'is-pending': drive.pending, 'is-ignored': drive.ignored }">
<div class="dh-drive__main">
<strong>{{ drive.range }}</strong>
<span>{{ drive.model }}</span>
</div>
<div class="dh-drive__meta">
<span>{{ drive.distance }}</span>
<span>{{ drive.duration }}</span>
</div>
<div class="dh-drive__row">
<div class="dh-drive__cell">
<div class="dh-track"><span :style="{ width: drive.engagedValue + '%' }"></span></div>
<span class="dh-drive__engaged" :class="{ 'dh-drive__muted': drive.pending || drive.ignored }">{{ drive.engaged }}</span>
</div>
<div class="dh-drive__attention">
<span v-for="a in drive.attentionItems" :key="a.text"><i :class="'bi ' + a.icon"></i>{{ a.text }}</span>
</div>
</div>
<button v-if="drive.canToggle" type="button" class="gx-manage-btn dh-drive__action" :disabled="isToggling(drive.routeNames)" @click="toggleDriveStats(drive)">
<i :class="'bi ' + drive.actionIcon"></i> {{ drive.actionLabel }}
</button>
</article>
</div>
</section>
<div class="dh-grid dh-grid--2">
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-stars"></i><span>Most used models</span></div>
<div v-if="modelView.hasModels" class="dh-body dh-models">
<div class="dh-chart-ring" :style="{ background: modelView.style }"></div>
<div class="dh-models__list">
<div v-for="m in modelView.rows" :key="m.name" class="dh-model">
<span class="dh-swatch" :style="{ background: m.color }"></span>
<div class="dh-model__body">
<strong>{{ m.name }}</strong>
<small>{{ m.label }}</small>
</div>
</div>
</div>
</div>
<div v-else class="gx-empty">No model usage recorded yet.</div>
</section>
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-box"></i><span>Storage</span></div>
<div class="dh-list">
<div class="gx-row dh-row--stack" style="cursor:default;">
<p class="dh-muted" style="margin:0;">{{ storageView.summary }}</p>
<div class="dh-track dh-track--lg"><span :style="{ width: storageView.usedPercent + '%' }"></span></div>
</div>
<div v-for="row in storageView.rows" :key="row.label" class="gx-row">
<span class="gx-row__label">{{ row.label }}</span>
<span class="gx-row__value">{{ row.value }}</span>
</div>
</div>
</section>
</div>
<div class="dh-label">Your device</div>
<div class="dh-grid dh-grid--2">
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-cpu"></i><span>Vitals</span></div>
<div class="dh-list">
<div v-for="v in vitalsList" :key="v.label" class="gx-row">
<span class="gx-row__label">{{ v.label }}</span>
<span class="gx-row__value">{{ v.value }}</span>
</div>
</div>
</section>
<section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-star"></i><span>Software</span></div>
<div class="dh-list">
<div v-for="row in softwareList" :key="row.label" class="gx-row">
<span class="gx-row__label">{{ row.label }}</span>
<span class="gx-row__value dh-software"><a v-if="row.href" :href="row.href" target="_blank" rel="noopener noreferrer">{{ row.value || 'unknown' }}</a><template v-else>{{ row.value || 'unknown' }}</template></span>
</div>
</div>
</section>
</div>
</template>
</div>
`,
}
@@ -1,7 +1,14 @@
import { api, showSnackbar } from "../api.js"
import { useLogStream } from "../composables.js"
import { useLogStream, useTabRouting } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
import { TroubleshootPanel } from "../components/TroubleshootPanel.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
const TABS = {
errors: "Error Logs",
tmux: "Tmux Live Log",
troubleshoot: "Troubleshoot",
}
function parseLogDate(filename) {
const m = filename.match(/(\d{4})-(\d{2})-(\d{2})[T_]?(\d{2})-?(\d{2})-?(\d{2})?/)
@@ -12,10 +19,10 @@ function parseLogDate(filename) {
export const Logs = {
name: "Logs",
components: { GalaxyEmbed },
components: { TroubleshootPanel, GalaxyTabs },
data() {
return {
tab: "errors",
TABS,
errorFiles: [],
errorsLoading: true,
selectedLog: "",
@@ -23,12 +30,13 @@ export const Logs = {
logLoading: false,
tmuxFiles: [],
tmuxLoading: true,
troubleshootData: null,
troubleshootRunning: false,
searchFilter: "",
tmuxAutoScroll: true,
}
},
setup() {
return useTabRouting("/logs", { errors: "errors", tmux: "tmux", troubleshoot: "troubleshoot" })
},
created() {
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
},
@@ -45,7 +53,6 @@ export const Logs = {
this.loadErrorLogs()
this.loadTmuxLogs()
this.stream.start()
this.loadTroubleshoot()
},
beforeUnmount() { this.stream.destroy() },
computed: {
@@ -140,26 +147,6 @@ export const Logs = {
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
@@ -169,16 +156,12 @@ export const Logs = {
<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>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<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>
<button v-if="errorFiles.length" type="button" class="gx-btn gx-btn--danger" @click="deleteAllLogs()">Delete All</button>
</div>
<section class="gx-card">
<div v-if="errorsLoading" class="gx-loading">Loading...</div>
@@ -190,7 +173,7 @@ export const Logs = {
</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>
<button type="button" class="gx-btn gx-btn--danger" @click.stop="deleteLog(f)"><i class="bi bi-trash"></i></button>
</div>
</div>
</section>
@@ -232,14 +215,14 @@ export const Logs = {
</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>
<button type="button" class="gx-btn gx-btn--danger" @click="deleteTmux(f)"><i class="bi bi-trash"></i></button>
</div>
</div>
</section>
</template>
<template v-else>
<GalaxyEmbed src="/troubleshoot" title="Troubleshoot" />
<TroubleshootPanel />
</template>
</div>
`,
@@ -0,0 +1,310 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
function text(value, fallback = "") {
return value === null || value === undefined ? fallback : String(value)
}
function releasedTs(value) {
const n = Date.parse(text(value, ""))
return Number.isNaN(n) ? 0 : n
}
export const ModelManager = {
name: "ModelManager",
data() {
return {
loading: true,
error: "",
busy: "",
sortMode: "release_date",
userFilter: "all",
communityFilter: "all",
allowGpu: false,
models: [],
currentModel: "",
summary: { installed: 0, missing: 0, total: 0 },
status: {
modelToDownload: "",
downloadAll: false,
downloading: false,
progress: "",
isOnroad: false,
},
}
},
computed: {
currentLabel() {
const key = text(this.currentModel, "")
if (!key) return "none"
const match = (this.models || []).find((m) => text(m && m.value, "") === key)
return match ? text(match.label, key) : key
},
sorted() {
const mode = this.sortMode
const rows = (this.models || [])
.filter((m) => m && typeof m === "object")
.filter((m) => this.userFilter === "all" ? true : !!m.userFavorite === (this.userFilter === "yes"))
.filter((m) => this.communityFilter === "all" ? true : !!m.communityFavorite === (this.communityFilter === "yes"))
rows.sort((a, b) => {
if (mode === "release_date") {
const delta = releasedTs(b.released) - releasedTs(a.released)
if (delta !== 0) return delta
}
return text(a.label, a.value).localeCompare(text(b.label, b.value), undefined, { sensitivity: "base", numeric: true })
})
return rows
},
anyGpuBlocked() {
return (this.models || []).some((m) => !!m.requiresGpu && !m.gpuAvailable)
},
downloadTargetLabel() {
if (this.status.downloadAll) return "all missing models"
const key = text(this.status.modelToDownload, "")
const match = (this.models || []).find((m) => text(m && m.value, "") === key)
return match ? text(match.label, key) : key || "a model"
},
},
created() {
this.poll = usePolling(() => this.refresh(), { interval: 2000 })
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
methods: {
gpuBlocked(model) {
return !!model.requiresGpu && !model.gpuAvailable && !this.allowGpu
},
rowState(model) {
const key = text(model.value, "")
if (key && key === this.currentModel) return "active"
if (this.status.downloading) {
const isTarget = !this.status.downloadAll && this.status.modelToDownload === key
if (this.status.downloadAll || isTarget) return "cancellable"
return "busy"
}
if (model.installed) return "installed"
return "available"
},
actionAllowedOnroad(action) {
if (action === "refresh" || action === "favorite" || action === "unfavorite") return true
return !this.status.isOnroad
},
async refresh() {
try {
const p = await api.getModelStatus()
this.models = Array.isArray(p.models) ? p.models.filter((m) => m && typeof m === "object") : []
this.currentModel = text(p.currentModel, "")
const s = p.summary && typeof p.summary === "object" ? p.summary : {}
this.summary = {
installed: Number(s.installed) || 0,
missing: Number(s.missing) || 0,
total: Number(s.total) || 0,
}
this.status = {
modelToDownload: text(p.modelToDownload, ""),
downloadAll: !!p.downloadAll,
downloading: !!p.downloading,
progress: text(p.progress, ""),
isOnroad: !!p.isOnroad,
}
this.error = ""
this.loading = false
} catch (e) {
this.error = e?.message || String(e)
this.loading = false
}
},
async runAction(action, model = null) {
if (this.busy) {
showSnackbar("Please wait for the current action to finish.", "error")
return
}
if (!this.actionAllowedOnroad(action)) {
showSnackbar("Actions are blocked while onroad.", "error")
return
}
const key = text(model && model.value, "")
const label = text(model && model.label, key || "model")
this.busy = `${action}:${key}`
try {
let msg = ""
if (action === "select") {
const p = await api.updateParam({ key: "Model", value: key })
msg = p?.message || `Selected "${label}".`
} else if (action === "download") {
const p = await api.startModelDownload(key, this.allowGpu)
msg = p?.message || `Downloading "${label}"...`
} else if (action === "downloadAll") {
const p = await api.downloadAllModels(this.allowGpu)
msg = p?.message || "Started downloading all models."
} else if (action === "cancel") {
const p = await api.postAction("/api/models/cancel")
msg = p?.message || "Cancellation requested."
} else if (action === "delete") {
const ok = await GalaxyConfirm({
title: "Delete model",
message: `Delete local files for "${label}"?`,
confirmLabel: "Delete",
danger: true,
})
if (!ok) return
const p = await api.deleteModel(key)
msg = p?.message || `Deleted files for "${label}".`
} else if (action === "favorite" || action === "unfavorite") {
const add = action === "favorite"
const favs = (this.models || [])
.filter((m) => m && m.userFavorite && text(m.value, "") !== key)
.map((m) => text(m.value, ""))
.filter(Boolean)
if (add) favs.push(key)
const p = await api.saveModelPreferences({ userFavorites: favs })
msg = p?.message || (add ? "Model added to your favorites." : "Model removed from your favorites.")
} else if (action === "refresh") {
const p = await api.postAction("/api/models/refresh_manifest")
msg = p?.message || "Model manifest refreshed."
}
if (msg) showSnackbar(msg)
await this.refresh()
} catch (e) {
showSnackbar(e?.message || String(e), "error")
} finally {
this.busy = ""
}
},
},
template: `
<div class="gx-view">
<div v-if="loading" class="gx-card">
<div class="gx-loading" style="padding: var(--sp-4);">Loading models...</div>
</div>
<template v-else>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Model Manager</span>
</div>
<div style="padding: var(--sp-3); display:flex; flex-wrap:wrap; gap:6px;">
<span class="gx-chip">{{ summary.installed }} installed</span>
<span class="gx-chip">{{ summary.missing }} missing</span>
<span class="gx-chip">{{ summary.total }} total</span>
<span class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Active: {{ currentLabel }}</span>
</div>
<div style="padding: 0 var(--sp-3) var(--sp-3);">
<div v-if="error" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 8px;">
<i class="bi bi-exclamation-triangle-fill gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Status load failed</strong><span>{{ error }}</span></div>
</div>
<div v-if="status.isOnroad" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 8px;">
<i class="bi bi-car-front-fill gx-alert__icon"></i>
<div class="gx-alert__body"><span>Onroad: switching models and downloads are disabled.</span></div>
</div>
<div v-if="status.downloading" class="gx-alert gx-alert--info" style="border:none; margin:0;">
<i class="bi bi-arrow-repeat gx-spin gx-alert__icon"></i>
<div class="gx-alert__body">
<strong>Downloading {{ downloadTargetLabel }}</strong>
<span v-if="status.progress">{{ status.progress }}</span>
</div>
</div>
</div>
</section>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-sliders"></i>
<span class="gx-section__title">Controls</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:12px;">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button v-if="status.downloading" type="button" class="gx-btn gx-btn--danger" :disabled="!!busy" @click="runAction('cancel')"><i class="bi bi-stop-circle"></i> Cancel Download</button>
<button v-else type="button" class="gx-btn" :disabled="!!busy" @click="runAction('downloadAll')"><i class="bi bi-download"></i> Download All Missing</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="runAction('refresh')"><i v-if="busy === 'refresh:'" class="bi bi-arrow-repeat gx-spin"></i><i v-else class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">Sort</span>
<select class="gx-field" style="flex:1;" :value="sortMode" @change="sortMode = $event.target.value">
<option value="release_date">Release Date</option>
<option value="alphabetical">Alphabetical</option>
</select>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<select class="gx-field" style="flex:1; min-width:140px;" :value="userFilter" @change="userFilter = $event.target.value">
<option value="all">Your Favorite: All</option>
<option value="yes">Your Favorite: Yes</option>
<option value="no">Your Favorite: No</option>
</select>
<select class="gx-field" style="flex:1; min-width:140px;" :value="communityFilter" @change="communityFilter = $event.target.value">
<option value="all">Community: All</option>
<option value="yes">Community: Yes</option>
<option value="no">Community: No</option>
</select>
</div>
<div class="gx-row" style="border-top:none;">
<div class="gx-row__info">
<span class="gx-row__label">Download GPU models without GPU</span>
<span v-if="anyGpuBlocked" class="gx-row__desc">GPU models are very large and will not run without an external GPU.</span>
</div>
<label class="gx-switch">
<input type="checkbox" v-model="allowGpu" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
</div>
</section>
<template v-if="!sorted.length">
<div class="gx-card"><div class="gx-empty">No models available.</div></div>
</template>
<template v-else>
<div class="gx-card-grid">
<section class="gx-card" v-for="m in sorted" :key="m.value">
<div style="display:flex; align-items:flex-start; gap:8px; padding: var(--sp-3);">
<div style="flex:1; min-width:0;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<strong>{{ m.label || m.value }}</strong>
<span v-if="m.userFavorite" class="gx-chip">Your Favorite</span>
<span v-if="m.communityFavorite" class="gx-chip">Community Favorite</span>
</div>
<div style="margin-top:6px; display:flex; flex-wrap:wrap; gap:6px;">
<span class="gx-chip">{{ m.value }}</span>
<span v-if="m.builtin" class="gx-chip">Built-in</span>
<span class="gx-chip">{{ m.requiresGpu ? 'eGPU' : 'On-device GPU' }}</span>
<span v-if="m.version" class="gx-chip">Version {{ m.version }}</span>
<span v-if="m.released" class="gx-chip">Released {{ m.released }}</span>
<span v-if="m.partial" class="gx-chip">Partial Files</span>
</div>
</div>
<button type="button" class="gx-icon-btn" :disabled="!!busy" :title="m.userFavorite ? 'Remove from your favorites' : 'Add to your favorites'" @click="runAction(m.userFavorite ? 'unfavorite' : 'favorite', m)">
<i class="bi" :class="m.userFavorite ? 'bi-star-fill' : 'bi-star'"></i>
</button>
</div>
<div style="padding: 0 var(--sp-3) var(--sp-3); display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<template v-if="rowState(m) === 'active'">
<span class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Active model</span>
</template>
<template v-else-if="rowState(m) === 'busy'">
<span class="gx-chip"><i class="bi bi-hourglass-split"></i> Busy</span>
</template>
<template v-else-if="rowState(m) === 'cancellable'">
<button type="button" class="gx-btn gx-btn--danger" :disabled="!!busy" @click="runAction('cancel', m)"><i class="bi bi-x-circle"></i> Cancel</button>
</template>
<template v-else-if="rowState(m) === 'installed'">
<button type="button" class="gx-btn" :disabled="!!busy" @click="runAction('select', m)"><i class="bi bi-play-fill"></i> Set Active</button>
<button v-if="!m.builtin" type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="!!busy" @click="runAction('delete', m)"><i class="bi bi-trash"></i> Delete</button>
</template>
<template v-else>
<button type="button" class="gx-btn" :disabled="!!busy || gpuBlocked(m)" @click="runAction('download', m)"><i class="bi bi-download"></i> {{ gpuBlocked(m) ? 'GPU Required' : 'Download' }}</button>
</template>
</div>
</section>
</div>
</template>
</template>
</div>
`,
}
@@ -1,37 +1,29 @@
import { api, showSnackbar } from "../api.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
import { MapsPanel } from "../components/MapsPanel.js"
import { NavigationKeysPanel } from "../components/NavigationKeysPanel.js"
import { SpeedLimitsPanel } from "../components/SpeedLimitsPanel.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { useTabRouting } from "../composables.js"
const TABS = {
nav: "Destination",
maps: "Maps",
keys: "App Keys",
speeds: "Speed Limits",
}
export const Navigation = {
name: "Navigation",
components: { GalaxySection, GalaxyEmbed },
components: { GalaxySection, MapsPanel, NavigationKeysPanel, SpeedLimitsPanel, GalaxyTabs },
data() {
return { destination: "", favorites: [], navLoading: true }
return { TABS, 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() {
@@ -60,12 +52,7 @@ export const Navigation = {
<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>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'nav'">
<GalaxySection title="Navigation Destination" icon="bi-geo-alt-fill">
@@ -83,7 +70,17 @@ export const Navigation = {
</GalaxySection>
</template>
<GalaxyEmbed v-else-if="embedSrc" :src="embedSrc" :title="embedTitle" />
<template v-if="tab === 'maps'">
<MapsPanel />
</template>
<template v-if="tab === 'keys'">
<NavigationKeysPanel />
</template>
<template v-if="tab === 'speeds'">
<SpeedLimitsPanel />
</template>
</div>
`,
}
@@ -0,0 +1,520 @@
import { api, showSnackbar } from "../api.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
const CANVAS_W = 640
const CANVAS_H = 480
const C4_ROAD_ASPECT = 476 / 240
const SIDE_LABELS = {
left: {
canvas: "LEFT SIDE OF VEHICLE - LEFT HERE",
button: "Set Left Side of Vehicle - Left Here",
moveButton: "Move Left Side of Vehicle - Left Here",
color: "#0d6efd",
},
right: {
canvas: "RIGHT SIDE OF VEHICLE - RIGHT HERE",
button: "Set Right Side of Vehicle - Right Here",
moveButton: "Move Right Side of Vehicle - Right Here",
color: "#fd7e14",
},
}
export const Pip = {
name: "Pip",
components: { GalaxySection },
props: { embedded: { type: Boolean, default: false } },
data() {
return {
armSide: null,
leftCenter: null,
rightCenter: null,
zoom: 240,
image: false,
configSaved: false,
configExists: false,
loading: false,
snapshotLoading: false,
error: "",
success: "",
deviceType: null,
loadedConfig: null,
}
},
computed: {
isC4() {
return (this.deviceType || "").toLowerCase() === "mici"
},
canvasStyle() {
return { width: "100%", height: "auto", display: "block", background: "#222", borderRadius: "var(--radius-md)", cursor: this.armSide ? "crosshair" : "default", touchAction: "manipulation" }
},
leftLabel() {
return this.leftCenter ? SIDE_LABELS.left.moveButton : SIDE_LABELS.left.button
},
rightLabel() {
return this.rightCenter ? SIDE_LABELS.right.moveButton : SIDE_LABELS.right.button
},
},
mounted() {
this._img = null
this._sizedFor = null
this.loadExistingConfig()
this.loadSnapshot()
this.$nextTick(() => this.redraw())
},
methods: {
scolor(side) {
return SIDE_LABELS[side]?.color || "#fff"
},
canvas() {
return this.$refs?.canvas || null
},
canvasScale() {
const canvas = this.canvas()
const img = this._img
const nativeW = img ? img.naturalWidth : 1920
const nativeH = img ? img.naturalHeight : 1080
const cw = canvas ? canvas.width || CANVAS_W : CANVAS_W
const ch = canvas ? canvas.height || CANVAS_H : CANVAS_H
return { cw, ch, nativeW, nativeH }
},
cropSource(center, zoom) {
const { cw, ch, nativeW, nativeH } = this.canvasScale()
const img = this._img
const [cx, cy] = center
const nativeCx = (cw - cx) * nativeW / cw
const nativeCy = cy * nativeH / ch
const nativeZoom = zoom * nativeW / cw
return { cw, ch, nativeW, nativeH, cx, cy, nativeCx, nativeCy, nativeZoom, img }
},
drawCurvedRect(ctx, x, y, w, h) {
const r = Math.min(w, h) * 0.22
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
},
drawC4Preview(ctx, center, zoom, color) {
const { ch, cx, cy, nativeCx, nativeCy, nativeZoom, img } = this.cropSource(center, zoom)
const cw = this.canvasScale().cw
let w = Math.min(zoom * 1.1, cw * 0.55)
w = Math.max(60, w)
let h = w / C4_ROAD_ASPECT
if (h > ch * 0.5) {
h = ch * 0.5
w = h * C4_ROAD_ASPECT
}
const x = cx - w / 2
const y = cy - h / 2
const aspect = w / h
const sx = nativeCx - nativeZoom / 2
const sh = nativeZoom / aspect
const sy = nativeCy - sh / 2
ctx.save()
this.drawCurvedRect(ctx, x, y, w, h)
ctx.fillStyle = "#000"
ctx.fill()
ctx.clip()
ctx.translate(x + w, 0)
ctx.scale(-1, 1)
ctx.drawImage(img, sx, sy, nativeZoom, sh, 0, y, w, h)
ctx.restore()
ctx.save()
this.drawCurvedRect(ctx, x, y, w, h)
ctx.strokeStyle = color
ctx.lineWidth = 2.5
ctx.stroke()
ctx.restore()
},
drawC3Preview(ctx, center, zoom, color) {
const { cx, cy, nativeCx, nativeCy, nativeZoom, img } = this.cropSource(center, zoom)
const half = zoom / 2
const sx = nativeCx - nativeZoom / 2
const sy = nativeCy - nativeZoom / 2
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, half, 0, Math.PI * 2)
ctx.fillStyle = "#000"
ctx.fill()
ctx.clip()
ctx.translate(cx + half, 0)
ctx.scale(-1, 1)
ctx.drawImage(img, sx, sy, nativeZoom, nativeZoom, 0, cy - half, zoom, zoom)
ctx.restore()
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, half, 0, Math.PI * 2)
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.stroke()
ctx.restore()
},
redraw() {
const canvas = this.canvas()
if (!canvas) return
const img = this._img
if (img && this._sizedFor !== img) {
this._sizedFor = img
canvas.width = Math.min(img.naturalWidth, 1280)
canvas.height = Math.round(canvas.width * (img.naturalHeight / img.naturalWidth))
} else if (!img && (canvas.width === 0 || this._sizedFor)) {
this._sizedFor = null
canvas.width = CANVAS_W
canvas.height = CANVAS_H
}
const ctx = canvas.getContext("2d")
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (img) {
ctx.save()
ctx.translate(canvas.width, 0)
ctx.scale(-1, 1)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
ctx.restore()
ctx.font = "bold 14px sans-serif"
ctx.textAlign = "center"
ctx.fillStyle = "rgba(0, 0, 0, 0.65)"
ctx.fillRect(0, 0, canvas.width / 2, 34)
ctx.fillRect(canvas.width / 2, 0, canvas.width / 2, 34)
ctx.fillStyle = "#fff"
ctx.fillText(SIDE_LABELS.left.canvas, canvas.width / 4, 22)
ctx.fillText(SIDE_LABELS.right.canvas, canvas.width * 3 / 4, 22)
} else {
ctx.fillStyle = "#222"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = "#888"
ctx.font = "16px monospace"
ctx.textAlign = "center"
ctx.fillText("Loading camera snapshot...", canvas.width / 2, canvas.height / 2)
return
}
const half = this.zoom / 2
const sides = [
{ key: "left", center: this.leftCenter, color: SIDE_LABELS.left.color, label: SIDE_LABELS.left.canvas },
{ key: "right", center: this.rightCenter, color: SIDE_LABELS.right.color, label: SIDE_LABELS.right.canvas },
]
for (const side of sides) {
if (!side.center) continue
const [cx, cy] = side.center
if (this.isC4) {
this.drawC4Preview(ctx, side.center, this.zoom, side.color)
} else {
this.drawC3Preview(ctx, side.center, this.zoom, side.color)
}
ctx.beginPath()
ctx.arc(cx, cy, 4, 0, Math.PI * 2)
ctx.fillStyle = "#fff"
ctx.fill()
ctx.strokeStyle = side.color
ctx.lineWidth = 1.5
ctx.stroke()
ctx.fillStyle = side.color
ctx.font = "bold 13px monospace"
ctx.textAlign = "center"
ctx.fillText(side.label, cx, cy - half - 8)
}
if (this.armSide) {
ctx.fillStyle = "#fff"
ctx.font = "bold 15px monospace"
ctx.textAlign = "center"
ctx.fillText(
this.armSide === "left"
? "Click LEFT HERE for the LEFT SIDE OF VEHICLE"
: "Click RIGHT HERE for the RIGHT SIDE OF VEHICLE",
canvas.width / 2,
canvas.height - 18,
)
}
},
async loadSnapshot() {
this.error = ""
this.success = ""
this.image = false
this.snapshotLoading = true
try {
const { src, cleanup } = await api.pipSnapshotSource()
const img = new Image()
img.onload = () => {
this._img = img
this._sizedFor = null
this.image = true
this.success = "Camera snapshot loaded. Place a center point on each window, then adjust the zoom."
this.applyConfigToCanvas()
this.redraw()
if (cleanup) cleanup()
}
img.onerror = () => {
this.image = false
this.error = "Failed to decode image"
this.redraw()
if (cleanup) cleanup()
}
img.src = src
} catch (e) {
this.image = false
this.error = e?.message || "Failed to load snapshot"
this.redraw()
} finally {
this.snapshotLoading = false
}
},
canvasClick(e) {
if (!this.image || !this.armSide || !e) return
const canvas = this.canvas()
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * (canvas.width / rect.width))
const y = Math.round((e.clientY - rect.top) * (canvas.height / rect.height))
if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) return
const leftHalf = x < canvas.width / 2
if ((this.armSide === "left" && !leftHalf) || (this.armSide === "right" && leftHalf)) {
this.error = this.armSide === "left"
? "LEFT SIDE OF VEHICLE is on the LEFT. Click the left half of the preview."
: "RIGHT SIDE OF VEHICLE is on the RIGHT. Click the right half of the preview."
this.success = ""
this.redraw()
return
}
if (this.armSide === "left") {
this.leftCenter = [x, y]
} else {
this.rightCenter = [x, y]
}
this.armSide = null
this.error = ""
this.redraw()
},
arm(side) {
this.armSide = side
this.error = ""
this.success = ""
this.redraw()
},
onZoom(e) {
const value = Number(e?.target?.value ?? 0)
if (!Number.isFinite(value)) return
this.zoom = value
this.redraw()
},
clearAll() {
this.leftCenter = null
this.rightCenter = null
this.armSide = null
this.configSaved = false
this.redraw()
},
async saveConfig() {
if (!this.leftCenter && !this.rightCenter) {
this.error = "Place at least one center point."
this.success = ""
return
}
const { cw, ch, nativeW, nativeH } = this.canvasScale()
const toNative = (center) => {
if (!center) return []
return [Math.round((cw - center[0]) * nativeW / cw), Math.round(center[1] * nativeH / ch)]
}
const config = {
width: nativeW,
height: nativeH,
center_left: toNative(this.rightCenter),
center_right: toNative(this.leftCenter),
crop_size: Math.round(this.zoom * nativeW / cw),
}
this.loading = true
this.error = ""
this.success = ""
try {
await api.setPipConfig(config)
this.configSaved = true
this.configExists = true
this.success = "PiP Preview mask saved!"
showSnackbar("PiP Preview mask saved!", "info")
await this.loadExistingConfig()
} catch (e) {
this.error = e?.message || "Failed to save"
showSnackbar(e?.message || "Save failed...", "error")
} finally {
this.loading = false
}
},
async loadExistingConfig() {
try {
const data = await api.getPipConfig()
this.deviceType = data?.device_type || this.deviceType || null
this.loadedConfig = data?.mask || null
this.applyConfigToCanvas()
} catch (e) {
console.error("PiP Preview config load failed", e)
}
},
applyConfigToCanvas() {
const config = this.loadedConfig
if (!config) {
this.leftCenter = null
this.rightCenter = null
this.configExists = false
this.redraw()
return
}
const { cw, ch, nativeW, nativeH } = this.canvasScale()
const toCanvas = (center) => {
if (!Array.isArray(center) || center.length < 2) return null
return [Math.round(cw - center[0] * cw / nativeW), Math.round(center[1] * ch / nativeH)]
}
this.leftCenter = toCanvas(config.center_right)
this.rightCenter = toCanvas(config.center_left)
if (Number.isFinite(Number(config.crop_size))) {
this.zoom = Math.round(Number(config.crop_size) * cw / nativeW)
}
this.configExists = Boolean(this.leftCenter || this.rightCenter)
this.redraw()
},
async deleteConfig() {
if (!(await GalaxyConfirm({
title: "Confirm Delete",
message: "Are you sure you want to delete the PiP Side Camera mask?",
confirmLabel: "Yes, Delete",
danger: true,
}))) return
this.loading = true
this.error = ""
this.success = ""
try {
await api.deletePipConfig()
this.leftCenter = null
this.rightCenter = null
this.armSide = null
this.configSaved = false
this.configExists = false
this.loadedConfig = null
this.success = "PiP Preview mask cleared."
showSnackbar("PiP Preview mask cleared.", "info")
this.redraw()
} catch (e) {
this.error = e?.message || "Failed to delete"
showSnackbar(e?.message || "Delete failed...", "error")
} finally {
this.loading = false
}
},
},
template: `
<div>
<h2 v-if="!embedded" style="margin-top:0;">PiP Side Camera Preview</h2>
<div style="display:grid; gap:var(--sp-4);">
<template v-if="!embedded">
<GalaxySection title="About PiP Preview" icon="bi-info-circle" :default-open="true">
<ul style="margin:0; padding-left:1.2em; color:var(--text-muted); line-height:1.6;">
<li>Shows a temporary Picture-in-Picture bubble of the adjacent side window while the turn signal is on or a blind spot is detected</li>
<li>Place a single center point on each window, then pick a shared zoom level</li>
<li>This mask is separate from the V-ASM detection mask, so you can tune the visual crop independently</li>
<li>Works alongside factory blind spot monitoring and/or V-ASM, and with turn signals alone</li>
</ul>
</GalaxySection>
<GalaxySection title="Setup" icon="bi-tools" :default-open="false">
<ul style="margin:0; padding-left:1.2em; color:var(--text-muted); line-height:1.6;">
<li>This preview is mirrored to match the normal on-road driver-camera view</li>
<li>The LEFT side of the vehicle is always on the LEFT here; the RIGHT side is always on the RIGHT</li>
<li>Click the matching side shown in the large labels. Raw camera-coordinate conversion is automatic</li>
<li>The zoom slider applies to BOTH windows so the preview stays consistent</li>
<li>At least one window center is required to enable the preview</li>
</ul>
</GalaxySection>
<div class="gx-row__desc" style="color:var(--text-muted); line-height:1.6; padding:0 2px;">
Use the preview to enhance lateral awareness. Always check manually before merging and be aware the driver camera view is from the cabin and does not reflect your blind spot.
</div>
</template>
<div v-if="error" class="gx-alert" style="border-left:4px solid var(--error); margin:0;">
<i class="bi bi-exclamation-triangle-fill gx-alert__icon" style="color:var(--error);"></i>
<div class="gx-alert__body"><span style="color:var(--error);">{{ error }}</span></div>
</div>
<div v-if="success" class="gx-alert gx-alert--info" style="margin:0;">
<i class="bi bi-check-circle-fill gx-alert__icon"></i>
<div class="gx-alert__body"><span>{{ success }}</span></div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="gx-btn" :style="armSide === 'left' ? { background: scolor('left'), color: '#fff' } : {}" @click="arm('left')">
<i class="bi bi-arrow-left"></i> {{ leftLabel }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" :style="armSide === 'right' ? { background: scolor('right'), color: '#fff' } : {}" @click="arm('right')">
<i class="bi bi-arrow-right"></i> {{ rightLabel }}
</button>
<button type="button" class="gx-btn" :disabled="loading || (!leftCenter && !rightCenter)" @click="saveConfig">
<i class="bi bi-save"></i> {{ loading ? 'Saving...' : 'Save Mask' }}
</button>
<button v-if="configExists" type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="loading" @click="deleteConfig">
<i class="bi bi-trash"></i> Delete Mask
</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="clearAll"><i class="bi bi-x-circle"></i> Clear All</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="snapshotLoading" @click="loadSnapshot">
<i class="bi bi-camera"></i> {{ snapshotLoading ? 'Loading...' : 'Get a new Snapshot' }}
</button>
</div>
<div v-if="armSide" class="gx-alert gx-alert--warn" style="margin:0;">
<i class="bi" :class="armSide === 'left' ? 'bi-arrow-left' : 'bi-arrow-right'" :style="{ color: scolor(armSide) }"></i>
<div class="gx-alert__body">
<strong>{{ armSide === 'left' ? 'LEFT SIDE OF VEHICLE - LEFT HERE' : 'RIGHT SIDE OF VEHICLE - RIGHT HERE' }}</strong>
<span>Click the matching side of the mirrored preview</span>
</div>
</div>
<div style="width:100%;">
<canvas ref="canvas"
@click="canvasClick"
:style="canvasStyle"></canvas>
<p style="margin:6px 0 0; color:var(--text-muted); font-size:var(--fs-sm); text-align:center;">
{{ armSide ? 'Click the window to place its center point.' : 'Place a center point on each window, then use the zoom slider below.' }}
</p>
</div>
<div class="gx-card" style="padding: var(--sp-4);">
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:8px;">
<strong>Zoom</strong>
<span class="gx-chip">{{ zoom }} px</span>
</div>
<input type="range" class="gx-field gx-field--full" style="padding:0;" min="60" max="640" step="5"
:value="zoom" @input="onZoom" />
<div style="display:flex; justify-content:space-between; margin-top:4px;">
<span class="gx-row__desc" style="margin:0;">Wide</span>
<span class="gx-row__desc" style="margin:0;">Close</span>
</div>
<p class="gx-row__desc" style="margin:8px 0 0;">Applied to both windows so the preview stays consistent.</p>
</div>
<GalaxySection v-if="!embedded" title="Preview Settings" icon="bi-sliders" :default-open="false">
<ul style="margin:0; padding-left:1.2em; color:var(--text-muted); line-height:1.6;">
<li>Enable "PiP Side Preview" in Toggles -> Visual (Display &amp; UI) -> Driving Screen Widgets</li>
<li>Choose to show the preview on the turn signal, on blind spot detection, or both</li>
</ul>
</GalaxySection>
</div>
</div>
`,
}
@@ -0,0 +1,642 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
const MAX_POINTS = 240
const POLL_INTERVAL_MS = 750
const ADVANCED_TERMS_KEY = "plotsShowAdvancedTerms"
const QUALITY_WINDOW_SECONDS = 30
const QUALITY_MIN_SAMPLES = 8
const LATERAL_QUALITY_CONFIG = {
desiredKey: "desiredLateralAccel",
actualKey: "actualLateralAccel",
minSpeedMps: 0.5,
minDemand: 0.008,
allowLowDemandFallback: true,
fallbackMinSpeedMps: 0.5,
fallbackMinPeakDemand: 0.01,
great: 0.15,
good: 0.30,
fair: 0.50,
}
const LONGITUDINAL_QUALITY_CONFIG = {
desiredKey: "desiredLongitudinalAccel",
actualKey: "actualLongitudinalAccel",
minSpeedMps: 0.0,
minDemand: 0.05,
allowLowDemandFallback: true,
fallbackMinSpeedMps: 1.5,
fallbackMinPeakDemand: 0.04,
applyPersistenceRules: true,
warnError: 0.50,
severeError: 0.90,
great: 0.32,
good: 0.52,
fair: 0.78,
}
const LINE_COLORS = {
desired: "#7aa2f7",
actual: "#9ece6a",
p: "#5ec8c8",
i: "#d4a060",
d: "#e05577",
f: "#bb9af7",
}
const TONE_COLORS = {
great: "#6cc56e",
good: "#5ec8c8",
fair: "#d4a060",
poor: "#e05577",
na: "var(--text-muted)",
}
function toNumber(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
// Canvas 2D cannot interpret CSS custom properties ("var(--x)"), so it silently
// keeps the previous colour (default black) when assigned one -> invisible text
// in dark mode. Resolve the token to a concrete colour from the live theme.
function cssColor(name, fallback) {
try {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
if (value && !value.startsWith("var(")) return value
} catch (e) {}
return fallback
}
function fmtNum(value, digits = 2) {
return toNumber(value).toFixed(digits)
}
function formatAge(seconds) {
const value = Math.max(0, toNumber(seconds))
if (value < 1) return `${Math.round(value * 1000)} ms`
return `${value.toFixed(1)} s`
}
function formatSourceLabel(kind, source) {
const normalizedKind = String(kind || "").toLowerCase()
const normalizedSource = String(source || "").toLowerCase()
if (normalizedKind === "lateral") {
if (normalizedSource === "torquestate") return "Steering controller output"
if (normalizedSource === "curvature") return "Path model estimate"
}
if (normalizedKind === "longitudinal") {
if (normalizedSource.includes("atarget")) return "Planner target acceleration + measured acceleration"
if (normalizedSource.includes("pid sum")) return "PID term sum + measured acceleration"
if (normalizedSource.includes("caroutput")) return "Final accel command + measured acceleration"
if (normalizedSource.includes("livelocationkalman")) return "Control output + calibrated acceleration"
if (normalizedSource === "controlsstate") return "Planner target output"
}
if (normalizedKind === "lateralterms") {
if (normalizedSource === "torquestate") return "Steering torque controller"
if (normalizedSource === "pidstate") return "Steering angle PID controller"
}
if (normalizedKind === "longitudinalterms") {
if (normalizedSource === "controlsstate") return "Longitudinal controller terms"
}
return "Live control signal"
}
function percentile(sortedValues, percentileValue) {
const values = Array.isArray(sortedValues) ? sortedValues : []
if (!values.length) return 0
const p = Math.max(0, Math.min(1, Number(percentileValue)))
const index = (values.length - 1) * p
const lower = Math.floor(index)
const upper = Math.ceil(index)
if (lower === upper) return values[lower]
const weight = index - lower
return values[lower] * (1 - weight) + values[upper] * weight
}
function signalMagnitude(sample, config) {
const desired = Math.abs(toNumber(sample?.[config.desiredKey], 0))
const actual = Math.abs(toNumber(sample?.[config.actualKey], 0))
return Math.max(desired, actual)
}
function computeQuality(samples, config) {
const safeSamples = Array.isArray(samples) ? samples : []
if (safeSamples.length < 2) {
return { label: "na", value: null, detail: "Waiting for data" }
}
const latestTs = toNumber(safeSamples[safeSamples.length - 1]?.timestamp, 0)
const cutoffTs = latestTs > 0 ? latestTs - QUALITY_WINDOW_SECONDS : 0
const recentSamples = safeSamples.filter((sample) => toNumber(sample?.timestamp, 0) >= cutoffTs)
const eligibleSignalSamples = recentSamples.filter((sample) => {
const speed = Math.abs(toNumber(sample?.speed, 0))
const signal = signalMagnitude(sample, config)
const speedOk = config.minSpeedMps <= 0 ? true : speed >= config.minSpeedMps
const demandOk = config.minDemand <= 0 ? true : signal >= config.minDemand
return speedOk && demandOk
})
let eligibleSamples = eligibleSignalSamples
let usedLowDemandFallback = false
const allowLowDemandFallback = config.allowLowDemandFallback !== false
if (allowLowDemandFallback && eligibleSamples.length < QUALITY_MIN_SAMPLES && recentSamples.length >= QUALITY_MIN_SAMPLES) {
const totalSpeed = recentSamples.reduce((sum, sample) => sum + Math.abs(toNumber(sample?.speed, 0)), 0)
const avgSpeed = recentSamples.length > 0 ? totalSpeed / recentSamples.length : 0
const peakDemand = recentSamples.reduce((peak, sample) => Math.max(peak, signalMagnitude(sample, config)), 0)
const fallbackMinSpeed = Math.max(0, toNumber(config.fallbackMinSpeedMps, 0))
const fallbackMinPeakDemand = Math.max(0, toNumber(config.fallbackMinPeakDemand, 0))
if (avgSpeed >= fallbackMinSpeed && peakDemand >= fallbackMinPeakDemand) {
eligibleSamples = recentSamples
usedLowDemandFallback = true
}
}
if (eligibleSamples.length < QUALITY_MIN_SAMPLES) {
const qualifier = allowLowDemandFallback ? "eligible" : "signal"
return {
label: "na",
value: null,
detail: `Need ${QUALITY_MIN_SAMPLES} samples (${eligibleSamples.length} ${qualifier} / ${recentSamples.length} total)`,
}
}
const errors = eligibleSamples
.map((sample) => Math.abs(toNumber(sample?.[config.desiredKey], 0) - toNumber(sample?.[config.actualKey], 0)))
.sort((a, b) => a - b)
if (!errors.length) {
return { label: "na", value: null, detail: "No eligible samples" }
}
const p50 = percentile(errors, 0.50)
const p90 = percentile(errors, 0.90)
const robustError = 0.7 * p50 + 0.3 * p90
const sampleSummary = `${eligibleSamples.length} samples / ${QUALITY_WINDOW_SECONDS}s${usedLowDemandFallback ? ", low-demand fallback" : ""}`
if (config.applyPersistenceRules) {
const warnThreshold = Math.max(config.warnError || 0.35, config.good || 0.35)
const severeThreshold = Math.max(config.severeError || 0.70, config.fair || 0.55)
const warnFrac = errors.filter((value) => value > warnThreshold).length / errors.length
const severeFrac = errors.filter((value) => value > severeThreshold).length / errors.length
let label = "poor"
if (robustError <= config.great && warnFrac <= 0.18 && severeFrac <= 0.05) label = "great"
else if (robustError <= config.good && warnFrac <= 0.34 && severeFrac <= 0.12) label = "good"
else if (robustError <= config.fair && warnFrac <= 0.55 && severeFrac <= 0.24) label = "fair"
const warnPct = Math.round(warnFrac * 100)
const severePct = Math.round(severeFrac * 100)
return {
label,
value: robustError,
detail: `${sampleSummary}, ${warnPct}% > ${fmtNum(warnThreshold)} and ${severePct}% > ${fmtNum(severeThreshold)}`,
}
}
let label = "poor"
if (robustError <= config.great) label = "great"
else if (robustError <= config.good) label = "good"
else if (robustError <= config.fair) label = "fair"
return { label, value: robustError, detail: sampleSummary }
}
function buildChartConfigs() {
return [
{
id: "lateral",
advanced: false,
title: "Lateral Response",
unit: "m/s²",
legendDigits: 2,
rangeDigits: 2,
rangeFloor: 1.5,
rangeMult: 1.25,
roundDiv: 10,
sourceKind: "lateral",
sourceKey: "lateralSource",
series: [
{ key: "desiredLateralAccel", label: "Target", legendClass: "desired", lineKey: "desired" },
{ key: "actualLateralAccel", label: "Measured", legendClass: "actual", lineKey: "actual" },
],
},
{
id: "longitudinal",
advanced: false,
title: "Longitudinal Response",
unit: "m/s²",
legendDigits: 2,
rangeDigits: 2,
rangeFloor: 1.5,
rangeMult: 1.25,
roundDiv: 10,
sourceKind: "longitudinal",
sourceKey: "longitudinalSource",
series: [
{ key: "desiredLongitudinalAccel", label: "Target", legendClass: "desired", lineKey: "desired" },
{ key: "actualLongitudinalAccel", label: "Measured", legendClass: "actual", lineKey: "actual" },
],
},
{
id: "lateralTerms",
advanced: true,
title: "Lateral Controller Terms",
unit: "",
legendDigits: 3,
rangeDigits: 3,
rangeFloor: 0.15,
rangeMult: 1.35,
roundDiv: 1000,
sourceKind: "lateralterms",
sourceKey: "lateralTermsSource",
series: [
{ key: "lateralP", label: "P", legendClass: "p", lineKey: "p" },
{ key: "lateralI", label: "I", legendClass: "i", lineKey: "i" },
{ key: "lateralD", label: "D", legendClass: "d", lineKey: "d" },
{ key: "lateralF", label: "F", legendClass: "f", lineKey: "f" },
],
},
{
id: "longitudinalTerms",
advanced: true,
title: "Longitudinal Accel Cmd Terms",
unit: "",
legendDigits: 3,
rangeDigits: 3,
rangeFloor: 0.15,
rangeMult: 1.35,
roundDiv: 1000,
sourceKind: "longitudinalterms",
sourceKey: "longitudinalTermsSource",
series: [
{ key: "longitudinalUpAccelCmd", label: "Up", legendClass: "p", lineKey: "p" },
{ key: "longitudinalUiAccelCmd", label: "Ui", legendClass: "i", lineKey: "i" },
{ key: "longitudinalUfAccelCmd", label: "Uf", legendClass: "f", lineKey: "f" },
],
},
]
}
export const Plots = {
name: "Plots",
props: { embedded: { type: Boolean, default: false } },
data() {
return {
loading: true,
error: "",
paused: false,
showAdvancedTerms: false,
live: null,
samples: [],
}
},
created() {
this.charts = buildChartConfigs()
this.lastTs = 0
try {
this.showAdvancedTerms = localStorage.getItem(ADVANCED_TERMS_KEY) === "1"
} catch (e) { this.showAdvancedTerms = false }
this.poll = usePolling(() => this.load(), { interval: POLL_INTERVAL_MS, enabled: () => !this.paused })
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
computed: {
shownCharts() {
return this.charts.filter((c) => !c.advanced || this.showAdvancedTerms)
},
sourceLabelMap() {
const live = this.live || {}
return this.charts.reduce((map, c) => {
map[c.id] = formatSourceLabel(c.sourceKind, live[c.sourceKey])
return map
}, {})
},
statusCard() {
const live = this.live || {}
return {
onroad: live.isOnroad ? "Yes" : "No",
age: formatAge(live.sampleAgeSeconds),
speed: fmtNum(live.speed),
count: this.samples.length,
bootStabilizing: !!live.bootStabilizing,
lastError: live.lastError || "",
}
},
qualities() {
return {
lateral: computeQuality(this.samples, LATERAL_QUALITY_CONFIG),
longitudinal: computeQuality(this.samples, LONGITUDINAL_QUALITY_CONFIG),
}
},
empty() {
return this.samples.length <= 1
},
},
methods: {
setChartRef(chart, el) {
if (chart) chart.el = el || null
},
latestValue(key) {
const latest = this.samples[this.samples.length - 1]
if (latest) return latest[key]
return toNumber(this.live?.[key])
},
chartLegend(chart) {
return chart.series.map((s) => ({
color: LINE_COLORS[s.lineKey],
label: s.label,
value: fmtNum(this.latestValue(s.key), chart.legendDigits),
}))
},
toneStyle(label) {
return { color: TONE_COLORS[label] || TONE_COLORS.na }
},
qualitySentence(kind) {
const q = this.qualities[kind]
const toneLabel = q.label === "na" ? "N/A" : q.label[0].toUpperCase() + q.label.slice(1)
const base = kind === "lateral" ? "Your lateral tuning is" : "Your longitudinal tuning is"
const error = q.value === null ? q.detail : `${fmtNum(q.value)} m/s² error (${q.detail})`
return { base, toneLabel, error, label: q.label }
},
pushSample(payload) {
const timestamp = toNumber(payload.timestamp, 0)
if (!timestamp || timestamp <= 0 || timestamp === this.lastTs) return
this.lastTs = timestamp
const sample = {
timestamp,
speed: toNumber(payload.speed),
desiredLateralAccel: toNumber(payload.desiredLateralAccel),
actualLateralAccel: toNumber(payload.actualLateralAccel),
desiredLongitudinalAccel: toNumber(payload.desiredLongitudinalAccel),
actualLongitudinalAccel: toNumber(payload.actualLongitudinalAccel),
lateralP: toNumber(payload.lateralP),
lateralI: toNumber(payload.lateralI),
lateralD: toNumber(payload.lateralD),
lateralF: toNumber(payload.lateralF),
longitudinalUpAccelCmd: toNumber(payload.longitudinalUpAccelCmd),
longitudinalUiAccelCmd: toNumber(payload.longitudinalUiAccelCmd),
longitudinalUfAccelCmd: toNumber(payload.longitudinalUfAccelCmd),
}
this.samples.push(sample)
if (this.samples.length > MAX_POINTS) {
this.samples.splice(0, this.samples.length - MAX_POINTS)
}
},
async load() {
try {
const payload = await api.getPlotsLive()
this.live = payload && typeof payload === "object" ? payload : this.live
this.error = ""
this.loading = false
if (payload && !payload.stale) this.pushSample(payload)
this.$nextTick(() => this.redraw())
} catch (e) {
this.error = e?.message || "Failed to load live plot data"
this.loading = false
throw e
}
},
redraw() {
for (const chart of this.charts) {
if (chart.el) this.drawChart(chart)
}
},
computeHalf(samples, keys, floor, mult, roundDiv) {
let maxAbs = 0
for (const sample of samples) {
for (const key of keys) {
const v = Math.abs(toNumber(sample?.[key]))
if (v > maxAbs) maxAbs = v
}
}
return Math.max(floor, Math.ceil(maxAbs * mult * roundDiv) / roundDiv)
},
drawChart(chart) {
const cv = chart.el
const ctx = cv.getContext("2d")
const dpr = window.devicePixelRatio || 1
const W = cv.clientWidth || 360
const H = cv.clientHeight || 240
cv.width = Math.round(W * dpr)
cv.height = Math.round(H * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
const samples = this.samples
const n = samples.length
const pad = 8
if (n <= 1) {
ctx.fillStyle = cssColor("--text-muted", "#999999")
ctx.font = "12px system-ui, sans-serif"
ctx.textAlign = "center"
ctx.fillText("Waiting for enough live samples...", W / 2, H / 2)
return
}
const keys = chart.series.map((s) => s.key)
const half = this.computeHalf(samples, keys, chart.rangeFloor, chart.rangeMult, chart.roundDiv)
const min = -half
const max = half
const span = Math.max(1e-6, max - min)
const clampVal = (v) => Math.max(min, Math.min(max, toNumber(v)))
const yFor = (v) => pad + ((max - clampVal(v)) / span) * (H - 2 * pad)
const gridStyle = cssColor("--glass-border", "rgba(128,128,128,0.25)")
const axisStyle = cssColor("--text-muted", "#aaaaaa")
ctx.lineWidth = 1
for (const gv of [max * 0.5, 0, min * 0.5]) {
ctx.strokeStyle = gv === 0 ? axisStyle : gridStyle
ctx.beginPath()
ctx.moveTo(0, yFor(gv))
ctx.lineTo(W, yFor(gv))
ctx.stroke()
}
ctx.font = "10px system-ui, sans-serif"
ctx.fillStyle = cssColor("--text-muted", "#999999")
ctx.textAlign = "left"
const unitSuffix = chart.unit ? ` ${chart.unit}` : ""
ctx.fillText(fmtNum(max, chart.rangeDigits) + unitSuffix, 3, pad + 6)
ctx.fillText("0", 3, yFor(0) + 3)
ctx.fillText(fmtNum(min, chart.rangeDigits) + unitSuffix, 3, H - 4)
const last = samples[samples.length - 1]
if (last) {
ctx.textAlign = "right"
ctx.fillText(`-${formatAge(toNumber(Date.now() / 1000, 0) - toNumber(last.timestamp, 0))}`, W - 3, pad + 6)
}
ctx.lineWidth = 1.5
ctx.lineJoin = "round"
for (const s of chart.series) {
ctx.strokeStyle = LINE_COLORS[s.lineKey]
ctx.beginPath()
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * W
const y = yFor(samples[i][s.key])
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
},
togglePaused() {
this.paused = !this.paused
if (this.paused) return
this.load().catch((e) => {
this.error = e?.message || "Failed to resume live data"
})
if (this.poll) this.poll.start()
},
async clearHistory() {
this.samples = []
this.lastTs = 0
this.$nextTick(() => this.redraw())
showSnackbar("Plot history cleared.")
},
toggleAdvancedTerms() {
this.showAdvancedTerms = !this.showAdvancedTerms
try {
localStorage.setItem(ADVANCED_TERMS_KEY, this.showAdvancedTerms ? "1" : "0")
} catch (e) { console.warn("Failed to persist plots advanced terms preference", e) }
this.$nextTick(() => this.redraw())
},
retry() {
this.loading = true
this.error = ""
this.load().catch((e) => {
this.error = e?.message || String(e)
this.loading = false
})
},
},
template: `
<div class="gx-view">
<h2 v-if="!embedded" style="margin-top:0;">Plots</h2>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-graph-up-arrow"></i>
<span class="gx-section__title">Live tuning status</span>
</div>
<div style="padding: var(--sp-3);">
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-3);">
Live comparison view for tuning diagnostics. These scores are a quick health check, not a final verdict.
Short spikes from bumps, lane changes, traffic transitions, and manual inputs can temporarily lower a score.
</p>
<div v-if="loading" class="gx-loading">Loading live data...</div>
<div v-if="error" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 var(--sp-2);">
<i class="bi bi-exclamation-triangle-fill gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Error:</strong> <span>{{ error }}</span></div>
</div>
<div v-if="!loading || live">
<div class="gx-row" style="border-top:none;"><span class="gx-row__label">Onroad</span><span class="gx-row__value">{{ statusCard.onroad }}</span></div>
<div class="gx-row"><span class="gx-row__label">Sample Age</span><span class="gx-row__value">{{ statusCard.age }}</span></div>
<div class="gx-row"><span class="gx-row__label">Vehicle Speed</span><span class="gx-row__value">{{ statusCard.speed }} m/s</span></div>
<div class="gx-row"><span class="gx-row__label">Samples</span><span class="gx-row__value">{{ statusCard.count }}</span></div>
<template v-if="statusCard.bootStabilizing">
<div class="gx-alert gx-alert--warn" style="margin: var(--sp-3) 0 0;">
<i class="bi bi-hourglass-split gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Boot stabilizing</strong><span>Plots are warming up after startup.</span></div>
</div>
</template>
<div v-if="statusCard.lastError" class="gx-alert gx-alert--warn" style="margin: var(--sp-3) 0 0;">
<i class="bi bi-exclamation-triangle gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Source Error:</strong> <span>{{ statusCard.lastError }}</span></div>
</div>
<div style="display:grid; gap: var(--sp-2); margin-top: var(--sp-3);">
<div v-for="q in [qualitySentence('lateral'), qualitySentence('longitudinal')]" :key="q.base">
<p style="margin:0; font-weight: var(--fw-bold, 600);">
{{ q.base }} <span :style="toneStyle(q.label)">{{ q.toneLabel }}</span>
</p>
<p style="margin:2px 0 0; color: var(--text-muted); font-size: var(--fs-xs, 0.8rem);">{{ q.error }}</p>
</div>
</div>
<p style="color: var(--text-muted); font-size: var(--fs-xs, 0.8rem); margin: var(--sp-3) 0 0; line-height:1.6;">
Match rating uses a 30-second rolling window. Strong steering or accel moments are preferred, but gentler
windows can still earn a rating. Longitudinal also checks how much of the window stays above error limits.
</p>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-top: var(--sp-3);">
<button type="button" class="gx-btn gx-btn--tonal" @click="togglePaused">
<i class="bi" :class="paused ? 'bi-play-fill' : 'bi-pause-fill'"></i> {{ paused ? 'Resume Live' : 'Pause Live' }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="clearHistory">
<i class="bi bi-trash"></i> Clear History
</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="retry">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
</section>
<div style="display:grid; gap: var(--sp-3); margin-top: var(--sp-3);">
<section v-for="chart in shownCharts.filter(c => !c.advanced)" :key="chart.id" class="gx-card" style="overflow:hidden;">
<div class="gx-section__header">
<i class="bi bi-activity"></i>
<span class="gx-section__title">{{ chart.title }}</span>
</div>
<div style="padding: var(--sp-3);">
<p style="margin:0 0 var(--sp-2); color: var(--text-muted); font-size: var(--fs-xs, 0.8rem);">
Source: {{ sourceLabelMap[chart.id] }}
</p>
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom: var(--sp-2);">
<span v-for="item in chartLegend(chart)" :key="item.label" style="display:inline-flex; align-items:center; gap:4px; font-size: var(--fs-xs, 0.8rem);">
<i style="width:10px; height:3px; border-radius:2px; display:inline-block; background: item.color;"></i>
{{ item.label }}: {{ item.value }}
</span>
</div>
<div v-if="!empty" style="width:100%; position:relative;">
<canvas :ref="(el) => setChartRef(chart, el)" style="width:100%; height:220px; display:block;"></canvas>
</div>
<div v-else class="gx-empty">Waiting for enough live samples...</div>
</div>
</section>
</div>
<div style="margin-top: var(--sp-3);">
<button type="button" class="gx-btn gx-btn--tonal" @click="toggleAdvancedTerms">
<i class="bi" :class="showAdvancedTerms ? 'bi-eye-slash' : 'bi-sliders'"></i>
{{ showAdvancedTerms ? 'Hide Advanced Controller Terms' : 'Show Advanced Controller Terms' }}
</button>
</div>
<div v-if="showAdvancedTerms" style="display:grid; gap: var(--sp-3); margin-top: var(--sp-3);">
<section v-for="chart in shownCharts.filter(c => c.advanced)" :key="chart.id" class="gx-card" style="overflow:hidden;">
<div class="gx-section__header">
<i class="bi bi-sliders"></i>
<span class="gx-section__title">{{ chart.title }}</span>
</div>
<div style="padding: var(--sp-3);">
<p style="margin:0 0 var(--sp-2); color: var(--text-muted); font-size: var(--fs-xs, 0.8rem);">
Source: {{ sourceLabelMap[chart.id] }}
</p>
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom: var(--sp-2);">
<span v-for="item in chartLegend(chart)" :key="item.label" style="display:inline-flex; align-items:center; gap:4px; font-size: var(--fs-xs, 0.8rem);">
<i style="width:10px; height:3px; border-radius:2px; display:inline-block; background: item.color;"></i>
{{ item.label }}: {{ item.value }}
</span>
</div>
<div v-if="!empty" style="width:100%; position:relative;">
<canvas :ref="(el) => setChartRef(chart, el)" style="width:100%; height:220px; display:block;"></canvas>
</div>
<div v-else class="gx-empty">Waiting for enough live samples...</div>
</div>
</section>
</div>
</div>
`,
}
@@ -1,6 +1,7 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
function fmtDuration(seconds) {
seconds = Number(seconds) || 0
@@ -32,7 +33,7 @@ function normalizeRoute(r) {
export const Recordings = {
name: "Recordings",
components: { GalaxySection },
components: { GalaxySection, GalaxyTabs },
data() {
return {
loading: true,
@@ -116,6 +117,9 @@ export const Recordings = {
showSnackbar("Delete failed.", "error")
}
},
setPreservedFilter(key) {
this.showPreservedOnly = key === "preserved"
},
async togglePreserved(route) {
try {
await api.setRoutePreserved(route.name, !route.is_preserved)
@@ -224,8 +228,9 @@ export const Recordings = {
<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>
<div style="padding: 0 var(--sp-3) var(--sp-3);">
<GalaxyTabs :items="{ all: 'All', preserved: 'Preserved' }" :active="showPreservedOnly ? 'preserved' : 'all'" @select="setPreservedFilter" />
</div>
</section>
@@ -242,7 +247,7 @@ export const Recordings = {
<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>
<button type="button" class="gx-btn gx-btn--danger" title="Delete" @click.stop="deleteRoute(r)"><i class="bi bi-trash"></i></button>
</div>
</article>
</section>
@@ -254,7 +259,7 @@ export const Recordings = {
</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>
<button type="button" class="gx-btn gx-btn--danger" @click="deleteAllRoutes(true)">Delete All Including Preserved</button>
</div>
</section>
@@ -273,33 +278,35 @@ export const Recordings = {
</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>
<Teleport to="body">
<transition name="gx-fade">
<div v-if="playerRoute" class="gx-scrim gx-scrim--bottomsheet" @click.self="closePlayer">
<div class="gx-sheet" 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>
</div>
</transition>
</transition>
</Teleport>
</div>
`,
}
@@ -0,0 +1,386 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
function b64ToBytes(value) {
const padding = "=".repeat((4 - (value.length % 4)) % 4)
const normalized = (value + padding).replace(/-/g, "+").replace(/_/g, "/")
const raw = window.atob(normalized)
return Uint8Array.from(raw, (ch) => ch.charCodeAt(0))
}
export const Sentry = {
name: "Sentry",
components: { GalaxySection },
data() {
return {
loading: true,
savingKey: "",
params: {},
status: {},
event: {},
history: [],
historyVisible: false,
historyBusy: false,
liveCapture: {},
testBusy: false,
liveBusy: false,
deleteBusy: false,
pushBusy: false,
}
},
created() {
this.poll = usePolling(() => this.loadStatus(), { interval: 5000 })
this.poll.start()
},
mounted() { this.loadParams() },
beforeUnmount() { this.poll?.destroy() },
computed: {
statusText() { return String(this.status?.state || "unknown") },
hasEvent() { return !!(this.event && this.event.eventId) },
},
methods: {
async loadParams() {
try {
this.params = (await api.getParams()) || {}
} catch (e) {
showSnackbar("Failed to load Sentry settings.", "error")
} finally {
this.loading = false
}
},
async loadStatus() {
try {
const payload = await api.getSentryStatus()
this.status = payload?.status || {}
this.event = payload?.lastEvent || {}
if (this.historyVisible && !this.historyBusy) this.loadHistory()
} catch (e) {
if (!this.loading) showSnackbar("Failed to load Sentry status.", "error")
}
},
async loadHistory() {
this.historyBusy = true
try {
const payload = await api.getSentryEvents()
this.history = Array.isArray(payload?.events) ? payload.events : []
} catch (e) {
showSnackbar("Failed to load Sentry history.", "error")
} finally {
this.historyBusy = false
}
},
async toggleHistory() {
this.historyVisible = !this.historyVisible
if (this.historyVisible) this.loadHistory()
},
numeric(key, fallback) {
const n = Number(this.params[key])
return Number.isFinite(n) ? n : fallback
},
async saveParam(key, value) {
this.savingKey = key
try {
const payload = await api.updateParam({ key, value })
this.params = { ...this.params, ...(payload?.updated || {}), [key]: value }
showSnackbar(payload?.message || "Sentry setting saved.")
} catch (e) {
showSnackbar(e?.data?.error || e?.message || `Failed to update ${key}.`, "error")
} finally {
this.savingKey = ""
}
},
async sendTestEvent() {
if (this.testBusy) return
this.testBusy = true
try {
await api.postAction("/api/sentry/test")
showSnackbar("Test capture started. The images will appear here shortly.")
} catch (e) {
showSnackbar(e?.data?.error || "Sentry test failed.", "error")
} finally {
this.testBusy = false
}
},
async viewLive() {
if (this.liveBusy) return
this.liveBusy = true
try {
this.liveCapture = await api.getSentryLive()
showSnackbar("Live camera snapshot captured.")
} catch (e) {
showSnackbar(e?.message || "Live camera capture failed.", "error")
} finally {
this.liveBusy = false
}
},
kindLabel(kind) {
return String(kind || "event").toUpperCase()
},
kindColor(kind) {
const k = String(kind || "")
if (k === "alarm") return "var(--error)"
if (k === "warning") return "var(--warning)"
if (k === "selfie") return "var(--primary)"
return "var(--text-muted)"
},
liveImageUrl(url) {
const cacheKey = encodeURIComponent(this.liveCapture?.capturedAt || "")
return cacheKey ? `${url}?t=${cacheKey}` : url
},
async enablePush() {
if (this.pushBusy) return
this.pushBusy = true
try {
if (typeof Notification === "undefined" || !("serviceWorker" in navigator) || !("PushManager" in window)) {
showSnackbar("This browser does not support Web Push notifications.", "error")
return
}
if (!window.isSecureContext) {
showSnackbar("Browser notifications require Galaxy over HTTPS.", "error")
return
}
const permission = await Notification.requestPermission()
if (permission !== "granted") {
showSnackbar("Browser notification permission was not granted.", "error")
return
}
const config = await api.getSentryPushConfig()
if (!config?.enabled || !config?.publicKey) {
showSnackbar(config?.error || "Galaxy Web Push is unavailable.", "error")
return
}
await navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: b64ToBytes(config.publicKey),
})
}
const body = typeof subscription.toJSON === "function"
? subscription.toJSON()
: {
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys: {
p256dh: subscription.getKey ? btoa(String.fromCharCode(...new Uint8Array(subscription.getKey("p256dh")))) : "",
auth: subscription.getKey ? btoa(String.fromCharCode(...new Uint8Array(subscription.getKey("auth")))) : "",
},
}
await api.sentryPushSubscribe(body)
showSnackbar("Browser notifications enabled for this device.")
} catch (e) {
showSnackbar(e?.message || "Could not enable browser notifications.", "error")
} finally {
this.pushBusy = false
}
},
async sendTestNotification() {
if (this.pushBusy) return
this.pushBusy = true
try {
const payload = await api.postAction("/api/sentry/test-notification")
const channels = Object.entries(payload?.channels || {})
.filter(([, configured]) => configured)
.map(([channel]) => channel === "webPush" ? "browser" : channel)
showSnackbar(channels.length ? `Test notification sent through ${channels.join(", ")}.` : "Test notification sent.")
} catch (e) {
showSnackbar(e?.message || "Could not send the test notification.", "error")
} finally {
this.pushBusy = false
}
},
async deleteEvent(eventId) {
eventId = String(eventId || "")
if (!eventId || this.deleteBusy) return
if (!(await GalaxyConfirm({
title: "Delete Sentry event?",
message: "Delete this Sentry event and its camera images? This cannot be undone.",
confirmLabel: "Delete",
danger: true,
}))) return
this.deleteBusy = true
try {
await api.deleteSentryEvent(eventId)
this.history = this.history.filter((e) => String(e?.eventId || "") !== eventId)
if (String(this.event?.eventId || "") === eventId) {
this.event = {}
this.loadStatus()
}
showSnackbar("Sentry event deleted.")
} catch (e) {
showSnackbar(e?.data?.error || e?.message || "Sentry event deletion failed.", "error")
} finally {
this.deleteBusy = false
}
},
},
template: `
<div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap;">
<div>
<h2 style="margin-top:0; margin-bottom:4px;">Sentry Mode</h2>
<p style="margin:0; color:var(--text-muted);">Monitor the parked vehicle and review movement captures.</p>
</div>
<span class="gx-chip">{{ statusText }}</span>
</div>
<GalaxySection title="Configuration" icon="bi-sliders" :collapsible="false">
<div style="padding: var(--sp-3);">
<div v-if="loading" class="gx-loading">Loading Sentry settings...</div>
<template v-else>
<div class="gx-row" style="border-top:none;">
<div class="gx-row__info">
<span class="gx-row__label"><strong>Enable Sentry Mode</strong></span>
<span class="gx-row__desc">Detect sustained movement while the vehicle is parked.</span>
</div>
<label class="gx-switch">
<input type="checkbox" :checked="!!params.SentryModeEnabled" :disabled="savingKey === 'SentryModeEnabled'"
@change="saveParam('SentryModeEnabled', $event.target.checked)" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
<div style="padding: var(--sp-2) 0;">
<div class="gx-row__label">Webhook URL</div>
<input class="gx-field gx-field--full" type="url" :value="params.SentryModeWebhook || ''"
placeholder="https://..." :disabled="savingKey === 'SentryModeWebhook'"
@change="saveParam('SentryModeWebhook', $event.target.value.trim())" />
<div class="gx-row__desc">Optional Discord-compatible or custom webhook.</div>
</div>
<div style="padding: var(--sp-2) 0;">
<div class="gx-row__label">ntfy URL</div>
<input class="gx-field gx-field--full" type="url" :value="params.SentryModeNtfyUrl || ''"
placeholder="https://ntfy.sh/..." :disabled="savingKey === 'SentryModeNtfyUrl'"
@change="saveParam('SentryModeNtfyUrl', $event.target.value.trim())" />
<div class="gx-row__desc">Optional ntfy topic URL for phone notifications.</div>
</div>
<div class="gx-row" style="border-top:none; align-items:stretch; flex-direction:column; gap:var(--sp-2);">
<div class="gx-row__label"><strong>Motion sensitivity</strong></div>
<div class="gx-slider-row">
<span class="gx-row__value">{{ numeric('SentryModeSensitivity', 0.04).toFixed(3) }}</span>
<input type="range" class="gx-slider" min="0.005" max="1" step="0.001" :value="numeric('SentryModeSensitivity', 0.04)"
:disabled="savingKey === 'SentryModeSensitivity'" aria-label="Motion sensitivity"
@change="saveParam('SentryModeSensitivity', Number($event.target.value))" />
</div>
<div class="gx-row__desc">Lower values detect smaller acceleration changes. Default: 0.04.</div>
</div>
<div class="gx-row" style="border-top:none; align-items:stretch; flex-direction:column; gap:var(--sp-2);">
<div class="gx-row__label"><strong>Warning persistence</strong></div>
<div class="gx-slider-row">
<span class="gx-row__value">{{ numeric('SentryModeWarningTime', 1).toFixed(1) }} seconds</span>
<input type="range" class="gx-slider" min="0.1" max="10" step="0.1" :value="numeric('SentryModeWarningTime', 1)"
:disabled="savingKey === 'SentryModeWarningTime'" aria-label="Warning persistence"
@change="saveParam('SentryModeWarningTime', Number($event.target.value))" />
</div>
<div class="gx-row__desc">How long movement must continue before the first alert. Default: 1 second.</div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:var(--sp-2);">
<button type="button" class="gx-btn" :disabled="pushBusy" @click="enablePush">
<i class="bi bi-bell"></i> {{ pushBusy ? 'Enabling...' : 'Enable browser notifications' }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="pushBusy" @click="sendTestNotification">
{{ pushBusy ? 'Sending...' : 'Send test notification' }}
</button>
</div>
<p class="gx-note">The test notification uses every configured channel: browser Web Push, ntfy, and webhook.</p>
<p class="gx-note">iPhone users: add Galaxy to your Home Screen as a web app before enabling notifications.</p>
</template>
</div>
</GalaxySection>
<GalaxySection title="Live view" icon="bi-camera" :collapsible="false">
<div style="padding: var(--sp-3);">
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">Capture one still from both cameras while parked.</span>
<span class="gx-row__value">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="liveBusy" @click="viewLive">
<i class="bi bi-camera"></i> {{ liveBusy ? 'Capturing...' : 'View live' }}
</button>
</span>
</div>
<template v-if="Array.isArray(liveCapture.imageUrls) && liveCapture.imageUrls.length">
<p class="gx-row__desc">Captured {{ liveCapture.capturedAt || 'just now' }}.</p>
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<a v-for="(u, i) in liveCapture.imageUrls" :key="u + i" :href="liveImageUrl(u)" target="_blank" rel="noopener" style="flex:1 1 45%; min-width:120px;">
<img :src="liveImageUrl(u)" :alt="'Live Sentry camera ' + (i + 1)" style="width:100%; border-radius:8px; display:block;" />
</a>
</div>
</template>
<p v-else class="gx-empty">No live snapshot captured yet.</p>
</div>
</GalaxySection>
<GalaxySection title="Latest Event" icon="bi-shield-exclamation" :collapsible="false">
<div style="padding: var(--sp-3);">
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:var(--sp-2);">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="historyBusy" @click="toggleHistory">
{{ historyBusy ? 'Loading...' : (historyVisible ? 'Hide history' : 'View history') }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="testBusy" @click="sendTestEvent">
{{ testBusy ? 'Capturing...' : 'Send test capture' }}
</button>
<button v-if="hasEvent" type="button" class="gx-btn gx-btn--danger" :disabled="deleteBusy" @click="deleteEvent(event.eventId)">
{{ deleteBusy ? 'Deleting...' : 'Delete event' }}
</button>
</div>
<p class="gx-row__desc">Events refresh automatically every five seconds.</p>
<template v-if="hasEvent">
<div style="display:flex; align-items:center; gap:8px; margin-top:var(--sp-2);">
<span class="gx-chip" :style="{ color: kindColor(event.kind) }">{{ kindLabel(event.kind) }}</span>
<span class="gx-row__desc" style="margin:0;">{{ event.detectedAt || '' }}</span>
</div>
<p style="margin:8px 0;"><strong>{{ event.message || 'Movement detected while parked.' }}</strong></p>
<template v-if="Array.isArray(event.imageUrls) && event.imageUrls.length">
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<a v-for="(u, i) in event.imageUrls" :key="u + i" :href="u" target="_blank" rel="noopener" style="flex:1 1 45%; min-width:120px;">
<img :src="u" :alt="'Sentry capture ' + (i + 1)" loading="lazy" style="width:100%; border-radius:8px; display:block;" />
</a>
</div>
</template>
<p v-else-if="event.kind === 'power_off'" class="gx-empty">Power-off alerts do not include camera captures because the device is shutting down.</p>
<p v-else class="gx-empty">No camera images were available for this event.</p>
</template>
<p v-else-if="!loading" class="gx-empty">No Sentry events recorded yet.</p>
<div v-if="historyVisible" style="margin-top:var(--sp-3); border-top:1px solid var(--border-color, rgba(255,255,255,.08)); padding-top:var(--sp-2);">
<div v-if="historyBusy" class="gx-loading">Loading Sentry history...</div>
<template v-else-if="history.length">
<p class="gx-row__desc">{{ history.length }} event{{ history.length === 1 ? '' : 's' }} retained. Events stay here until you delete them.</p>
<article v-for="ev in history" :key="ev.eventId" style="border:1px solid var(--glass-border, rgba(255,255,255,.1)); border-radius:12px; padding:var(--sp-3); margin:var(--sp-2) 0;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<strong>{{ ev.detectedAt || 'Sentry event' }}</strong>
<span class="gx-chip" :style="{ color: kindColor(ev.kind) }">{{ kindLabel(ev.kind) }}</span>
</div>
<button type="button" class="gx-icon-btn" :disabled="deleteBusy" @click="deleteEvent(ev.eventId)" title="Delete event" style="color:var(--error);">
<i class="bi bi-trash"></i>
</button>
</div>
<p style="margin:8px 0;"><strong>{{ ev.message || 'Movement detected while parked.' }}</strong></p>
<template v-if="Array.isArray(ev.imageUrls) && ev.imageUrls.length">
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<a v-for="(u, i) in ev.imageUrls" :key="u + i" :href="u" target="_blank" rel="noopener" style="flex:1 1 45%; min-width:120px;">
<img :src="u" :alt="'Sentry capture ' + (i + 1)" loading="lazy" style="width:100%; border-radius:8px; display:block;" />
</a>
</div>
</template>
<p v-else class="gx-empty">No camera images were available for this event.</p>
</article>
</template>
<p v-else class="gx-empty">No retained Sentry events.</p>
</div>
</div>
</GalaxySection>
</div>
`,
}
@@ -2,15 +2,21 @@ 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"
import { GxNotice } from "../components/GxNotice.js"
function shortCommit(commit) {
return String(commit || "").slice(0, 10) || "—"
}
function toPercent(value) {
const n = Number(value)
if (!Number.isFinite(n)) return 0
return Math.max(0, Math.min(100, n))
}
export const SystemTools = {
name: "SystemTools",
components: { GalaxySection, GalaxyEmbed },
components: { GalaxySection, GxNotice },
data() {
return {
branches: [],
@@ -26,6 +32,21 @@ export const SystemTools = {
beforeUnmount() { this.poll?.destroy() },
computed: {
updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
factoryResetStatus() {
const s = this.fastStatus
if (!s || String(s?.lastMode || "").trim() !== "factory-reset") return null
return {
running: !!s.running,
stage: String(s.stage || "idle"),
message: String(s.message || ""),
lastError: String(s.lastError || ""),
progressLabel: String(s.progressLabel || ""),
progressDetail: String(s.progressDetail || ""),
progressPercent: toPercent(s.progressPercent),
progressStep: Number(s.progressStep || 0),
progressTotalSteps: Number(s.progressTotalSteps || 5),
}
},
},
methods: {
shortCommit,
@@ -155,16 +176,7 @@ export const SystemTools = {
}
},
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
if (!(await GalaxyConfirm({ title: "Factory reset (SAVE ME)?", 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("SAVE ME initiated — factory resetting...")
@@ -186,23 +198,11 @@ export const SystemTools = {
<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">
<GalaxySection title="Software & Updates" icon="bi-arrow-up-circle" :collapsible="false">
<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>
<GxNotice v-if="isOnroad" text="Updates and branch switching are only available while offroad." style="margin-bottom:12px;" />
<div v-if="fastStatus" class="gx-card" style="margin-bottom:12px;">
<div class="gx-section__header">
@@ -217,21 +217,24 @@ export const SystemTools = {
<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.message" class="gx-note">{{ fastStatus.message }}</div>
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ 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 v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-note gx-note--danger">⚠ {{ 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 class="gx-card" style="margin-bottom:12px;">
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Switch Branch</span></div>
<div style="padding: var(--sp-3);">
<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>
<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>
@@ -243,17 +246,46 @@ export const SystemTools = {
<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;">
<p class="gx-note">Check for Updates scans for a newer commit. Use <strong>Update Now</strong> to install it.</p>
<p class="gx-note"><strong>Recover</strong> continues an update that was interrupted (for example, by power loss mid-install). <strong>Rollback</strong> returns the device to the previously installed version if the current one has a problem.</p>
<p v-if="checkedForUpdates && !updateAvailable && !fastStatus?.running" class="gx-note">
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>
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat" :collapsible="false">
<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 gx-btn--danger" @click="deleteAllDrivingRoutes">Delete All Driving Routes</button>
<input ref="restoreInput" type="file" accept=".json" style="display:none;" @change="onRestoreFile" />
</div>
<p class="gx-note" style="padding: 0 var(--sp-4);">Backup downloads your toggle settings as a JSON file. Restore re-applies one, and Reset to Default clears them back to stock and reboots.</p>
<div v-if="factoryResetStatus" style="padding: var(--sp-3); border-top: 1px solid var(--border-color, rgba(255,255,255,.08));">
<div class="gx-section__header">
<i class="bi bi-arrow-repeat" :class="{ 'gx-spin': factoryResetStatus.running }"></i>
<span class="gx-section__title">Factory Reset Status</span>
<span class="gx-chip">{{ factoryResetStatus.progressStep }}/{{ factoryResetStatus.progressTotalSteps }}{{ factoryResetStatus.progressLabel ? ' · ' + factoryResetStatus.progressLabel : '' }}</span>
</div>
<div style="padding: var(--sp-3);">
<div style="height:8px; border-radius:999px; background:var(--surface, rgba(255,255,255,.1)); overflow:hidden;">
<div :style="{ height: '100%', width: factoryResetStatus.progressPercent + '%', background: factoryResetStatus.stage === 'error' ? 'var(--error)' : 'var(--primary)', transition: 'width .4s' }"></div>
</div>
<p v-if="factoryResetStatus.message" style="margin:8px 0 0;">{{ factoryResetStatus.message }}</p>
<p v-if="factoryResetStatus.progressDetail" class="gx-note" style="margin:4px 0 0;">{{ factoryResetStatus.progressDetail }}</p>
<p v-if="factoryResetStatus.lastError" class="gx-note gx-note--danger" style="margin:4px 0 0;">Last Error: {{ factoryResetStatus.lastError }}</p>
</div>
</div>
</GalaxySection>
<GalaxySection title="Danger Zone" icon="bi-exclamation-triangle" :collapsible="false">
<div style="padding: var(--sp-3); display:grid; gap:12px;">
<p class="gx-note" style="margin:0;">Last resort only. <strong>Factory Reset (also known as SAVE ME)</strong> wipes params, backups, themes, models, maps, and route data, then reboots the device. This cannot be undone.</p>
<button type="button" class="gx-btn gx-btn--danger" style="justify-self:start;" @click="factoryReset">Factory Reset Device (SAVE ME)</button>
</div>
</GalaxySection>
</div>
@@ -0,0 +1,259 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxySection } from "../components/GalaxySection.js"
function slotIdOf(slot) {
return String((slot && slot.id) || "").trim()
}
function isUnusedOf(slot) {
const name = String((slot && slot.name) || "").trim().toLowerCase()
return name === "unused" || name.startsWith("unused ")
}
function variantLabelsOf(slot) {
if (!slot || typeof slot !== "object") return { A: "A" }
const labels = {}
const raw = slot.variantLabels
if (raw && typeof raw === "object") {
for (const [key, value] of Object.entries(raw)) {
const mode = String(key || "").trim().toUpperCase()
const label = String(value || "").trim()
if (mode.length === 1 && /^[A-Z]$/.test(mode) && label) labels[mode] = label
}
}
const aLabel = String(slot.aLabel || "").trim()
const bLabel = String(slot.bLabel || "").trim()
if (aLabel) labels.A = labels.A || aLabel
if (bLabel) labels.B = labels.B || bLabel
if (!labels.A) labels.A = "A"
return Object.keys(labels).sort().reduce((acc, key) => {
acc[key] = labels[key]
return acc
}, {})
}
function variantModesOf(slot) {
return Object.keys(variantLabelsOf(slot))
}
function defaultModeOf(slot) {
const modes = variantModesOf(slot)
if (modes.includes("A")) return "A"
return modes[0] || "A"
}
function modeLabelOf(slot, mode) {
const labels = variantLabelsOf(slot)
const m = String(mode || "").trim().toUpperCase()
return labels[m] || m || "A"
}
export const TestingGround = {
name: "TestingGround",
props: { embedded: { type: Boolean, default: false } },
components: { GalaxySection },
data() {
return { loading: true, busy: false, error: "", data: null, selectedSlot: "" }
},
created() {
this.poll = usePolling(() => this.load(), { interval: 3000, enabled: () => !this.busy })
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
computed: {
slots() { return Array.isArray(this.data?.slots) ? this.data.slots : [] },
selectableSlots() {
const sel = Array.isArray(this.data?.selectableSlots) ? this.data.selectableSlots : []
if (sel.length) return sel
return this.slots.filter((slot) => !isUnusedOf(slot))
},
selectedSlotObj() {
const id = String(this.selectedSlot || "").trim()
if (!id) return null
return this.slots.find((slot) => slotIdOf(slot) === id) || null
},
activeSlotObj() {
const id = String(this.data?.activeSlot || "").trim()
if (!id) return null
return this.slots.find((slot) => slotIdOf(slot) === id) || null
},
activeModeLabel() {
const data = this.data
if (!data) return ""
return String(data.activeVariantLabel || "").trim() || (this.activeSlotObj
? modeLabelOf(this.activeSlotObj, String(data.activeVariant || "").trim().toUpperCase() || "A")
: String(data.activeVariant || "A"))
},
selectedModeDisplay() {
const slot = this.selectedSlotObj
if (!slot) return "Not active"
const data = this.data
if (String(data?.activeSlot || "").trim() === String(this.selectedSlot || "").trim()) {
const activeMode = String(data?.activeVariant || "").trim().toUpperCase() || defaultModeOf(slot)
return String(data?.activeVariantLabel || "").trim() || modeLabelOf(slot, activeMode)
}
return "Not active"
},
hasSelection() { return !!this.selectedSlotObj },
},
methods: {
slotIdOf,
isUnusedOf,
variantModesOf,
modeLabelOf,
defaultModeOf,
slotId(slot) { return slotIdOf(slot) },
isActiveSlot(slot) {
return !!slot && String(this.data?.activeSlot || "").trim() === slotIdOf(slot)
},
isModeActive(mode) {
const m = String(mode || "").trim().toUpperCase()
const data = this.data
return String(data?.activeSlot || "").trim() === String(this.selectedSlot || "").trim()
&& String(data?.activeVariant || "").trim().toUpperCase() === m
},
onSelectSlot(event) {
const value = String(event?.target?.value || "").trim()
if (!value || this.busy) return
this.selectedSlot = value
},
applyPayload(payload) {
if (!payload || typeof payload !== "object") throw new Error("Failed to load testing grounds")
this.data = payload
this.error = ""
const selectable = Array.isArray(payload.selectableSlots) ? payload.selectableSlots : []
const current = String(this.selectedSlot || "").trim()
const hasCurrent = selectable.some((slot) => slotIdOf(slot) === current)
if (!hasCurrent) {
const active = String(payload.activeSlot || "").trim()
const hasActive = selectable.some((slot) => slotIdOf(slot) === active)
this.selectedSlot = hasActive ? active : slotIdOf(selectable[0] || {})
}
},
async load() {
try {
const payload = await api.getOptions("/api/testing_grounds")
this.applyPayload(payload)
} catch (e) {
this.error = e?.message || "Failed to load testing grounds"
throw e
} finally {
this.loading = false
}
},
async applySelection(slotValue, mode, toast = true) {
const normalizedSlot = String(slotValue || "").trim()
const normalizedMode = String(mode || "").trim().toUpperCase()
if (!normalizedSlot || !normalizedMode) return false
this.busy = true
try {
const payload = await api.selectTestingGround({ slotId: normalizedSlot, variant: normalizedMode })
this.applyPayload(payload)
this.selectedSlot = normalizedSlot
this.error = ""
if (toast) {
const selectedSlot = Array.isArray(payload.slots)
? payload.slots.find((slot) => slotIdOf(slot) === normalizedSlot)
: null
const selectedLabel = String(payload.activeVariantLabel || "").trim() || modeLabelOf(selectedSlot, normalizedMode)
showSnackbar(payload.message || `Testing Ground ${normalizedSlot} set to ${selectedLabel}.`)
}
return true
} catch (e) {
const message = e?.message || "Failed to update testing ground mode"
this.error = message
if (toast) showSnackbar(message, "error")
return false
} finally {
this.busy = false
}
},
async selectMode(mode) {
if (this.busy) return
if (!this.selectedSlotObj) {
showSnackbar("Select a testing ground first.", "error")
return
}
await this.applySelection(slotIdOf(this.selectedSlotObj), mode, true)
},
},
template: `
<div>
<h2 v-if="!embedded" style="margin-top:0;">Testing Ground</h2>
<GalaxySection title="A/B Tuning Sandbox" icon="bi-sliders" :collapsible="false">
<div style="padding: var(--sp-3);">
<p class="gx-note" style="margin:0 0 var(--sp-3); line-height:1.6;">
A/B Tuning Sandbox. If you don't know what this is, you probably shouldn't be here ;)
</p>
<div v-if="loading" class="gx-loading">Loading testing ground state...</div>
<template v-if="!loading && data">
<div v-if="error" class="gx-alert" style="border:none;">{{ error }}</div>
<div v-if="!selectableSlots.length && !error" class="gx-empty">No active test slots.</div>
<template v-if="selectableSlots.length">
<div class="gx-section__header"><i class="bi bi-activity"></i><span class="gx-section__title">Status</span></div>
<div class="gx-row"><span class="gx-row__label">Selected Slot</span><span class="gx-row__value">{{ selectedSlotObj?.name || 'Unknown' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Selected Mode</span><span class="gx-row__value">{{ selectedModeDisplay }}</span></div>
<div class="gx-row"><span class="gx-row__label">Onroad</span><span class="gx-row__value">{{ data?.isOnroad ? 'Yes' : 'No' }}</span></div>
<div class="gx-row"><span class="gx-row__label">Selectable Slots</span><span class="gx-row__value">{{ selectableSlots.length }}</span></div>
<div class="gx-section__header" style="margin-top: var(--sp-3);">
<i class="bi bi-grid-1x2"></i><span class="gx-section__title">Current Test Slots</span><span class="gx-chip">{{ selectableSlots.length }}</span>
</div>
<ul style="list-style:none; margin:0; padding: var(--sp-2) var(--sp-4); display:grid; gap:6px;">
<li v-for="slot in selectableSlots" :key="slotId(slot)" class="gx-row"
style="border:none; min-height:0; padding:6px 10px;"
:style="isActiveSlot(slot) ? 'background:var(--primary-container); border-radius:var(--radius-md); color:var(--on-primary-container);' : ''">
<span class="gx-row__label" :style="isActiveSlot(slot) ? 'color:var(--on-primary-container);' : ''">{{ slotId(slot) }}. {{ slot.name }}</span>
</li>
</ul>
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">View Slot</span>
<select class="gx-field" :value="selectedSlot" :disabled="busy" @change="onSelectSlot">
<option v-for="slot in selectableSlots" :key="slotId(slot)" :value="slotId(slot)">{{ slotId(slot) }}. {{ slot.name }}</option>
</select>
</div>
<p class="gx-note">
Only one Testing Ground can be active at a time. Switching slots only changes what you're viewing; the active test stays enabled until you explicitly choose another mode.
</p>
<template v-if="hasSelection">
<div class="gx-section__header" style="margin-top: var(--sp-3);">
<i class="bi bi-bezier2"></i><span class="gx-section__title">{{ selectedSlotObj.name }}</span>
<span v-if="isActiveSlot(selectedSlotObj)" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Active</span>
</div>
<p v-if="selectedSlotObj.description" class="gx-note" style="margin:0 0 var(--sp-2);">{{ selectedSlotObj.description }}</p>
<div class="gx-tabs" style="margin-bottom:0;">
<button v-for="mode in variantModesOf(selectedSlotObj)" :key="mode" type="button"
class="gx-tab" :class="{ active: isModeActive(mode) }" :disabled="busy"
@click="selectMode(mode)">
<i class="bi" :class="isModeActive(mode) ? 'bi-check2-circle' : 'bi-circle'"></i>
{{ mode }} · {{ modeLabelOf(selectedSlotObj, mode) }}
</button>
</div>
<p class="gx-note">
Selecting a mode sets {{ selectedSlotObj.name }} as the active Testing Ground.
</p>
</template>
<div class="gx-section__header" style="margin-top: var(--sp-3);">
<i class="bi bi-check2-circle"></i><span class="gx-section__title">Currently Active</span>
</div>
<div v-if="activeSlotObj" class="gx-row" style="border:none; background:var(--primary-container); border-radius:var(--radius-md); min-height:0; padding:8px 12px; margin:0 var(--sp-3) var(--sp-3);">
<div class="gx-row__info"><span class="gx-row__label" style="color:var(--on-primary-container);">{{ activeSlotObj.id }}. {{ activeSlotObj.name }}</span></div>
<span class="gx-row__value" style="color:var(--on-primary-container);">Mode {{ activeModeLabel }}</span>
</div>
</template>
</template>
<div v-if="!loading && !data && error" class="gx-alert" style="border:none;">{{ error }}</div>
</div>
</GalaxySection>
</div>
`,
}
@@ -0,0 +1,600 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GxNotice } from "../components/GxNotice.js"
const COLOR_LABELS = {
LaneLines: "Lane Lines",
LeadMarker: "Lead Marker",
Path: "Path",
PathEdge: "Path Edge",
Sidebar1: "Sidebar Top",
Sidebar2: "Sidebar Middle",
Sidebar3: "Sidebar Bottom",
}
const DISTANCE_KEYS = ["traffic", "aggressive", "standard", "relaxed"]
const SOUND_KEYS = ["disengage", "engage", "prompt", "startup"]
const SOUND_LABELS = { disengage: "Disengage", engage: "Engage", prompt: "Prompt", startup: "Startup" }
function defaultColors() {
const base = { red: 23, green: 134, blue: 68, alpha: 255 }
return {
LaneLines: { ...base },
LeadMarker: { ...base },
Path: { ...base },
PathEdge: { red: 18, green: 107, blue: 54, alpha: 255 },
Sidebar1: { ...base },
Sidebar2: { ...base },
Sidebar3: { ...base },
}
}
function rgbToHex(c) {
const p = (n) => Math.max(0, Math.min(255, Math.round(n || 0))).toString(16).padStart(2, "0")
return "#" + p(c.red) + p(c.green) + p(c.blue)
}
function hexToRgb(hex) {
return {
red: parseInt(hex.slice(1, 3), 16),
green: parseInt(hex.slice(3, 5), 16),
blue: parseInt(hex.slice(5, 7), 16),
alpha: 255,
}
}
function emptyFiles() {
return {
distanceIcons: { traffic: null, aggressive: null, standard: null, relaxed: null },
homeButton: null,
settingsButton: null,
steeringWheel: null,
turnSignal: null,
turnSignalBlindspot: null,
sounds: { disengage: null, engage: null, prompt: null, startup: null },
}
}
function emptyNames() {
return {
distanceIcons: { traffic: "", aggressive: "", standard: "", relaxed: "" },
homeButton: "",
settingsButton: "",
steeringWheel: "",
turnSignal: "",
turnSignalBlindspot: "",
sounds: { disengage: "", engage: "", prompt: "", startup: "" },
}
}
export const ThemeMaker = {
name: "ThemeMaker",
components: { GalaxySection, GxNotice },
data() {
return {
colors: defaultColors(),
files: emptyFiles(),
names: emptyNames(),
labels: COLOR_LABELS,
soundLabels: SOUND_LABELS,
flags: { colors: true, distance_icons: false, icons: false, sounds: false, steering_wheel: false, turn_signals: false },
turnSignalStyle: "Static",
turnSignalLength: 100,
themeName: "",
themes: [],
loading: true,
loadedError: "",
busy: false,
}
},
async mounted() {
this.refreshThemeList()
try {
const data = await api.getThemeDefault()
if (data && data.colors && typeof data.colors === "object") {
this.colors = this.normalizeColors(data.colors)
this.flags.colors = true
}
} catch (e) {}
},
computed: {
colorKeys() {
return Object.keys(COLOR_LABELS)
},
sectionOptions() {
const opts = [{ key: "colors", label: "Colors" }]
if (this.hasContent("distance_icons")) opts.push({ key: "distance_icons", label: "Distance Icons" })
if (this.hasContent("icons")) opts.push({ key: "icons", label: "Icons" })
if (this.hasContent("sounds")) opts.push({ key: "sounds", label: "Sounds" })
if (this.hasContent("steering_wheel")) opts.push({ key: "steering_wheel", label: "Steering Wheel" })
if (this.hasContent("turn_signals")) opts.push({ key: "turn_signals", label: "Turn Signals" })
return opts
},
anyChecked() {
return this.sectionOptions.some((s) => this.flags[s.key])
},
canSave() {
return !this.busy && !!this.themeName.trim() && this.anyChecked
},
},
methods: {
normalizeColors(obj) {
const base = defaultColors()
for (const key of Object.keys(base)) {
const src = obj[key]
if (src && typeof src === "object") {
base[key] = {
red: Number(src.red) || 0,
green: Number(src.green) || 0,
blue: Number(src.blue) || 0,
alpha: Number(src.alpha) != null ? Number(src.alpha) : 255,
}
}
}
return base
},
hexFor(key) {
return rgbToHex(this.colors[key])
},
rgbaFor(key) {
const c = this.colors[key] || {}
const a = c.alpha != null ? Number(c.alpha) / 255 : 1
return "rgba(" + (c.red || 0) + "," + (c.green || 0) + "," + (c.blue || 0) + "," + a + ")"
},
onColorChange(e, key) {
this.colors[key] = hexToRgb(e.target.value)
this.flags.colors = true
},
resetColors() {
this.colors = defaultColors()
this.flags.colors = true
},
basename(path) {
return String(path || "").split("/").pop()
},
onFileChoose(e, kind, key, subkey) {
const file = e.target.files && e.target.files[0]
e.target.value = ""
if (!file) return
if (file.size > 5 * 1024 * 1024) {
showSnackbar("That file is too large! Please upload files under 5MB.", "error")
return
}
if (kind === "image" && !file.type.startsWith("image/")) {
showSnackbar("Invalid file type! Please upload an image file.", "error")
return
}
if (kind === "audio" && !file.type.startsWith("audio/")) {
showSnackbar("Invalid file type! Please upload an audio file.", "error")
return
}
if (subkey) {
this.files[key][subkey] = file
this.names[key][subkey] = file.name
} else {
this.files[key] = file
this.names[key] = file.name
}
const comp = key === "distanceIcons" ? "distance_icons"
: key === "homeButton" || key === "settingsButton" ? "icons"
: key === "steeringWheel" ? "steering_wheel"
: key === "turnSignal" || key === "turnSignalBlindspot" ? "turn_signals"
: kind === "audio" ? "sounds"
: null
if (comp) this.flags[comp] = true
},
clearFile(kind, key, subkey) {
if (subkey) {
this.files[key][subkey] = null
this.names[key][subkey] = ""
} else {
this.files[key] = null
this.names[key] = ""
}
},
hasContent(comp) {
if (comp === "colors") return true
if (comp === "distance_icons") return DISTANCE_KEYS.some((k) => this.files.distanceIcons[k])
if (comp === "icons") return !!(this.files.homeButton || this.files.settingsButton)
if (comp === "sounds") return SOUND_KEYS.some((k) => this.files.sounds[k])
if (comp === "steering_wheel") return !!this.files.steeringWheel
if (comp === "turn_signals") return !!(this.files.turnSignal || this.files.turnSignalBlindspot)
return false
},
fileRow(key, subkey, label) {
const name = subkey ? this.names[key][subkey] : this.names[key]
return { key, subkey, label, name }
},
getFormData() {
const fd = new FormData()
fd.append("themeName", this.themeName.trim() || "")
fd.append("saveChecklist", JSON.stringify(this.flags))
fd.append("selectedThemeSources", "{}")
if (this.flags.colors) fd.append("colors", JSON.stringify(this.colors))
if (this.flags.turn_signals) {
fd.append("turnSignalStyle", this.turnSignalStyle)
fd.append("turnSignalType", "Single Image")
fd.append("turnSignalLength", String(this.turnSignalLength))
if (this.files.turnSignal) fd.append("turnSignal", this.files.turnSignal)
if (this.files.turnSignalBlindspot) fd.append("turnSignalBlindspot", this.files.turnSignalBlindspot)
}
if (this.flags.icons) {
if (this.files.homeButton) fd.append("homeButton", this.files.homeButton)
if (this.files.settingsButton) fd.append("settingsButton", this.files.settingsButton)
}
if (this.flags.steering_wheel && this.files.steeringWheel) fd.append("steeringWheel", this.files.steeringWheel)
if (this.flags.distance_icons) {
for (const k of DISTANCE_KEYS) {
if (this.files.distanceIcons[k]) fd.append("distanceIcons_" + k, this.files.distanceIcons[k])
}
}
if (this.flags.sounds) {
for (const k of SOUND_KEYS) {
if (this.files.sounds[k]) fd.append(k, this.files.sounds[k])
}
}
return fd
},
async save() {
if (!this.canSave) { showSnackbar("Enter a theme name and select at least one component.", "error"); return }
this.busy = true
try {
const res = await api.saveTheme(this.getFormData())
showSnackbar(res?.message || "Theme saved!")
this.themeName = ""
await this.refreshThemeList()
} catch (e) {
showSnackbar(e?.data?.error || e?.message || "Failed to save theme.", "error")
} finally {
this.busy = false
}
},
async apply() {
if (!this.anyChecked) { showSnackbar("Select at least one component to apply.", "error"); return }
const confirmed = await GalaxyConfirm({
title: "Apply theme?",
message: "This makes the current colors/assets the active theme on device. It does not save them as a named theme.",
confirmLabel: "Apply",
})
if (!confirmed) return
this.busy = true
try {
const res = await api.applyTheme(this.getFormData())
showSnackbar(res?.message || "Theme applied!")
} catch (e) {
showSnackbar(e?.data?.error || e?.message || "Failed to apply theme.", "error")
} finally {
this.busy = false
}
},
async exportTheme() {
if (!this.themeName.trim()) { showSnackbar("Enter a theme name before exporting.", "error"); return }
if (!this.anyChecked) { showSnackbar("Select at least one component to export.", "error"); return }
this.busy = true
try {
const blob = await api.downloadTheme(this.getFormData())
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = this.themeName.trim().replace(/\s+/g, "_") + ".zip"
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
showSnackbar("Theme exported as a zip.")
} catch (e) {
showSnackbar(e?.message || "Failed to export theme.", "error")
} finally {
this.busy = false
}
},
async refreshThemeList() {
this.loading = true
try {
const data = await api.getThemeList()
this.themes = Array.isArray(data?.themes) ? data.themes.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""), undefined, { numeric: true })) : []
this.loadedError = ""
} catch (e) {
this.loadedError = "Failed to load themes."
} finally {
this.loading = false
}
},
compChips(t) {
const out = []
if (t.hasColors) out.push("Colors")
if (t.hasDistanceIcons) out.push("Distance Icons")
if (t.hasIcons) out.push("Icons")
if (t.hasSounds) out.push("Sounds")
if (t.hasSteeringWheel) out.push("Steering Wheel")
if (t.hasTurnSignals) out.push("Turn Signals")
return out
},
canDelete(t) {
return !this.busy && String(t?.type || "").toLowerCase() !== "holiday"
},
async loadThemeToEditor(t) {
if (this.busy) return
this.busy = true
try {
const data = await api.loadTheme(t.path, t.type)
const fetchFile = async (assetPath) => {
if (!assetPath) return null
try {
const blob = await api.getThemeAssetBlob(t.path, t.type, assetPath)
return new File([blob], this.basename(assetPath), { type: blob.type || "application/octet-stream" })
} catch (e) {
return null
}
}
let loadedAny = false
if (data?.colors && typeof data.colors === "object" && Object.keys(data.colors).length) {
this.colors = this.normalizeColors(data.colors)
this.flags.colors = true
loadedAny = true
}
const imgs = data?.images || {}
if (imgs.distanceIcons && typeof imgs.distanceIcons === "object") {
for (const k of DISTANCE_KEYS) {
const asset = imgs.distanceIcons[k]
if (asset && asset.path) {
const f = await fetchFile(asset.path)
if (f) { this.files.distanceIcons[k] = f; this.names.distanceIcons[k] = f.name; this.flags.distance_icons = true; loadedAny = true }
}
}
}
const setNamedFile = async (slotKey, asset) => {
if (!asset || !asset.path) return
const f = await fetchFile(asset.path)
if (f) { this.files[slotKey] = f; this.names[slotKey] = f.name; loadedAny = true }
}
await setNamedFile("homeButton", imgs.homeButton)
await setNamedFile("settingsButton", imgs.settingsButton)
await setNamedFile("steeringWheel", imgs.steeringWheel)
await setNamedFile("turnSignal", imgs.turnSignal)
await setNamedFile("turnSignalBlindspot", imgs.turnSignalBlindspot)
if (this.files.homeButton || this.files.settingsButton) this.flags.icons = true
if (this.files.steeringWheel) this.flags.steering_wheel = true
if (this.files.turnSignal || this.files.turnSignalBlindspot) this.flags.turn_signals = true
if (data?.sounds && typeof data.sounds === "object") {
for (const k of SOUND_KEYS) {
const asset = data.sounds[k]
if (asset && asset.path) {
const f = await fetchFile(asset.path)
if (f) { this.files.sounds[k] = f; this.names.sounds[k] = f.name; this.flags.sounds = true; loadedAny = true }
}
}
}
if (data?.turnSignalStyle) this.turnSignalStyle = data.turnSignalStyle
if (data?.turnSignalLength != null) this.turnSignalLength = Number(data.turnSignalLength) || 100
if (!loadedAny) showSnackbar("That theme has no loadable content.", "info")
else showSnackbar("Loaded \"" + t.name + "\" into the editor!")
} catch (e) {
showSnackbar(e?.message || "Failed to load theme.", "error")
} finally {
this.busy = false
}
},
async removeTheme(t) {
if (!this.canDelete(t)) return
const kind = t?.type === "steering_wheel" ? "steering wheel" : "theme"
const confirmed = await GalaxyConfirm({
title: "Delete theme?",
message: "Delete " + kind + " \"" + t.name + "\"? This cannot be undone.",
confirmLabel: "Delete",
danger: true,
})
if (!confirmed) return
this.busy = true
try {
const res = await api.deleteTheme(t.path, t.type)
showSnackbar(res?.message || "Theme deleted.")
await this.refreshThemeList()
} catch (e) {
showSnackbar(e?.data?.error || e?.message || "Failed to delete theme.", "error")
} finally {
this.busy = false
}
},
setStyle(s) {
this.turnSignalStyle = s
this.flags.turn_signals = true
},
onLengthBlur(e) {
let v = parseInt(e.target.value, 10)
if (isNaN(v)) v = 100
this.turnSignalLength = Math.max(25, Math.min(1000, v))
this.flags.turn_signals = true
},
},
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Theme Maker</h2>
<p style="color:var(--text-muted); margin-top:0;">Customize the on-road colors and assets. Changes apply to the active on-device theme; saving keeps them as a named theme.</p>
<GalaxySection title="Colors" icon="bi-palette">
<div style="padding: var(--sp-3); display:grid; gap:14px;">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<div v-for="key in colorKeys" :key="key" class="gx-card" style="margin:0; padding: var(--sp-2); display:flex; align-items:center; gap:10px;">
<span style="width:22px; height:22px; border-radius:6px; flex:none; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2);" :style="{ background: rgbaFor(key) }"></span>
<div style="flex:1; min-width:0;">
<div class="gx-row__label" style="font-size:var(--fs-xs);">{{ labels[key] }}</div>
<div style="font-family:monospace; font-size:12px; opacity:.8;">{{ hexFor(key) }}</div>
</div>
<input type="color" style="width:44px; height:32px; border:0; background:transparent; padding:0; cursor:pointer;" :value="hexFor(key)" @input="onColorChange($event, key)" />
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" class="gx-btn gx-btn--tonal" @click="resetColors">Reset to Stock Green</button>
</div>
</div>
</GalaxySection>
<GalaxySection title="Distance Icons" icon="bi-sign-turn-left" :default-open="true">
<div style="padding: var(--sp-3);">
<p class="gx-note" style="margin:0 0 var(--sp-2);">Recommended size 250x250.</p>
<div class="gx-tight-grid">
<div v-for="k in ['traffic','aggressive','standard','relaxed']" :key="k" class="gx-row" style="border:none; background:var(--surface); min-height:0; padding:6px 10px;">
<div style="flex:1; min-width:0;">
<div class="gx-row__label">{{ k.charAt(0).toUpperCase() + k.slice(1) }}</div>
<div class="gx-row__desc" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ names.distanceIcons[k] || 'No file selected' }}</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<label class="gx-btn gx-btn--tonal" style="margin:0; cursor:pointer;">Choose
<input type="file" accept="image/*" style="display:none;" @change="onFileChoose($event, 'image', 'distanceIcons', k)" />
</label>
<button type="button" v-if="files.distanceIcons[k]" class="gx-icon-btn" style="color:var(--error);" title="Clear" @click="clearFile('image', 'distanceIcons', k)"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</div>
</GalaxySection>
<GalaxySection title="Icons" icon="bi-grid" :default-open="true">
<div style="padding: var(--sp-3); display:grid; gap:10px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:6px 0;">
<div style="flex:1; min-width:0;">
<div class="gx-row__label">Home Button</div>
<div class="gx-row__desc" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ names.homeButton || 'No file selected' }}</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<label class="gx-btn gx-btn--tonal" style="margin:0; cursor:pointer;">Choose
<input type="file" accept="image/*" style="display:none;" @change="onFileChoose($event, 'image', 'homeButton')" />
</label>
<button type="button" v-if="files.homeButton" class="gx-icon-btn" style="color:var(--error);" title="Clear" @click="clearFile('image', 'homeButton')"><i class="bi bi-trash"></i></button>
</div>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:6px 0;">
<div style="flex:1; min-width:0;">
<div class="gx-row__label">Settings Button</div>
<div class="gx-row__desc" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ names.settingsButton || 'No file selected' }}</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<label class="gx-btn gx-btn--tonal" style="margin:0; cursor:pointer;">Choose
<input type="file" accept="image/*" style="display:none;" @change="onFileChoose($event, 'image', 'settingsButton')" />
</label>
<button type="button" v-if="files.settingsButton" class="gx-icon-btn" style="color:var(--error);" title="Clear" @click="clearFile('image', 'settingsButton')"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</GalaxySection>
<GalaxySection title="Sounds" icon="bi-volume-up" :default-open="true">
<div style="padding: var(--sp-3);">
<div class="gx-tight-grid">
<div v-for="k in ['disengage','engage','prompt','startup']" :key="k" class="gx-row" style="border:none; background:var(--surface); min-height:0; padding:6px 10px;">
<div style="flex:1; min-width:0;">
<div class="gx-row__label">{{ soundLabels[k] }}</div>
<div class="gx-row__desc" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ names.sounds[k] || 'No file selected' }}</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<label class="gx-btn gx-btn--tonal" style="margin:0; cursor:pointer;">Choose
<input type="file" accept="audio/*" style="display:none;" @change="onFileChoose($event, 'audio', 'sounds', k)" />
</label>
<button type="button" v-if="files.sounds[k]" class="gx-icon-btn" style="color:var(--error);" title="Clear" @click="clearFile('audio', 'sounds', k)"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</div>
</GalaxySection>
<GalaxySection title="Steering Wheel" icon="bi-bicycle" :default-open="true">
<div style="padding: var(--sp-3); display:grid; gap:10px;">
<p style="margin:0 0 4px; color:var(--text-muted);">Recommended size 250x250.</p>
<div class="gx-row" style="border-top:none; min-height:0; padding:6px 0;">
<div style="flex:1; min-width:0;">
<div class="gx-row__label">Steering Wheel Image</div>
<div class="gx-row__desc" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ names.steeringWheel || 'No file selected' }}</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<label class="gx-btn gx-btn--tonal" style="margin:0; cursor:pointer;">Choose
<input type="file" accept="image/*" style="display:none;" @change="onFileChoose($event, 'image', 'steeringWheel')" />
</label>
<button type="button" v-if="files.steeringWheel" class="gx-icon-btn" style="color:var(--error);" title="Clear" @click="clearFile('image', 'steeringWheel')"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</GalaxySection>
<GalaxySection title="Turn Signals" icon="bi-arrow-left-right" :default-open="true">
<div style="padding: var(--sp-3); display:grid; gap:12px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Style</span>
<span class="gx-row__value" style="display:flex; gap:6px;">
<button type="button" class="gx-btn gx-btn--text" :style="turnSignalStyle === 'Static' ? 'background:var(--primary); color:var(--on-primary);' : ''" @click="setStyle('Static')">Static</button>
<button type="button" class="gx-btn gx-btn--text" :style="turnSignalStyle === 'Traditional' ? 'background:var(--primary); color:var(--on-primary);' : ''" @click="setStyle('Traditional')">Traditional</button>
</span>
</div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;">
<span class="gx-row__label">Length (25-1000ms)</span>
<span class="gx-row__value">
<input class="gx-field" type="number" style="width:110px; text-align:right;" :value="turnSignalLength" @input="turnSignalLength = $event.target.value" @blur="onLengthBlur" />
</span>
</div>
<div v-for="f in [fileRow('turnSignal',null,'Turn Signal'), fileRow('turnSignalBlindspot',null,'Blind Spot')]" :key="f.label" class="gx-row" style="border-top:none; min-height:0; padding:6px 0;">
<div style="flex:1; min-width:0;">
<div class="gx-row__label">{{ f.label }}</div>
<div class="gx-row__desc" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ f.name || 'No file selected' }}</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<label class="gx-btn gx-btn--tonal" style="margin:0; cursor:pointer;">Choose
<input type="file" accept="image/*" style="display:none;" @change="onFileChoose($event, 'image', f.key)" />
</label>
<button type="button" v-if="files[f.key]" class="gx-icon-btn" style="color:var(--error);" title="Clear" @click="clearFile('image', f.key)"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</GalaxySection>
<GalaxySection title="Save / Apply" icon="bi-save">
<div style="padding: var(--sp-3); display:grid; gap:10px;">
<label class="gx-row__label" style="font-size:var(--fs-xs);">Theme Name</label>
<input class="gx-field gx-field--full" v-model="themeName" placeholder="Enter a name for this theme..." autocomplete="off" />
<p style="margin:4px 0 0; color:var(--text-muted);">Select which components to include:</p>
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<label v-for="s in sectionOptions" :key="s.key" class="gx-chip" style="cursor:pointer; user-select:none; gap:6px; display:inline-flex; align-items:center;">
<input type="checkbox" style="accent-color:var(--primary);" :checked="flags[s.key]" @change="flags[s.key] = !flags[s.key]" />
{{ s.label }}
</label>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end; margin-top:8px;">
<button type="button" class="gx-btn" :disabled="!canSave" @click="save">
<i v-if="busy" class="bi bi-arrow-repeat gx-spin"></i>
<i v-else class="bi bi-save"></i> Save Theme
</button>
<button type="button" class="gx-btn" :disabled="busy || !anyChecked" @click="apply"><i class="bi bi-check2-circle"></i> Apply Theme</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || !anyChecked" @click="exportTheme"><i class="bi bi-download"></i> Export (.zip)</button>
</div>
</div>
</GalaxySection>
<GalaxySection title="Theme Library" icon="bi-collection">
<div style="padding: var(--sp-3);">
<div v-if="loading" class="gx-loading">Loading themes...</div>
<GxNotice v-else-if="loadedError" tone="danger" :text="loadedError" />
<div v-else-if="!themes.length" class="gx-empty">No themes yet. Build one above and hit Save Theme.</div>
<template v-else>
<div class="gx-card-grid">
<div v-for="t in themes" :key="t.type + ':' + t.path" class="gx-card" style="padding: var(--sp-3); display:flex; flex-direction:column; gap:10px;">
<div style="flex:1; min-width:0;">
<div style="font-weight:600; display:flex; align-items:center; gap:6px;">
{{ t.name }}
<i v-if="t.is_user_created" class="bi bi-star-fill" style="color:var(--warning); font-size:12px;"></i>
<span class="gx-chip" v-if="t.type === 'holiday'">Holiday</span>
<span class="gx-chip" v-else-if="t.type === 'steering_wheel'">Wheel</span>
</div>
<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:6px;" v-if="compChips(t).length">
<span class="gx-chip" style="opacity:.85; font-size:11px;" v-for="c in compChips(t)" :key="c">{{ c }}</span>
</div>
</div>
<div style="display:flex; gap:6px; flex:none;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="loadThemeToEditor(t)" title="Load into editor"><i class="bi bi-pencil"></i> Load</button>
<button type="button" v-if="canDelete(t)" class="gx-icon-btn" style="color:var(--error);" title="Delete" @click="removeTheme(t)"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</template>
</div>
</GalaxySection>
</div>
`,
}
@@ -23,8 +23,6 @@ export const ToolEmbed = {
"/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",
@@ -1,18 +1,15 @@
import { navigate, toolHref } from "../store.js"
const TOOLS = [
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2", desc: "Pairing & remote access" },
{ name: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video", desc: "PiP side camera & V-ASM spot monitor" },
{ name: "Galaxy & App Install", link: "/galaxy", icon: "bi-globe2", desc: "Remote access, pairing, & app install" },
{ 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: "Tuning, Plots & Testing", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed tuning, live plots, testing grounds" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" },
].sort((a, b) => a.name.localeCompare(b.name))
@@ -0,0 +1,117 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
export const Tsk = {
name: "Tsk",
data() {
return { keys: [], selectedKeyName: "", keyName: "", keyValue: "", loading: true }
},
async mounted() { await this.load() },
computed: {
duplicateName() {
return this.keys.some((k) => k.name === this.keyName && k.name !== this.selectedKeyName)
},
},
methods: {
async load() {
try {
this.keys = await api.getTskKeys()
} catch (e) {
showSnackbar("Failed to load keys...", "error")
} finally {
this.loading = false
}
},
selectKey(name) {
if (!name) { this.selectedKeyName = ""; return }
const selected = this.keys.find((k) => k.name === name)
if (selected) {
this.selectedKeyName = selected.name
this.keyName = selected.name
this.keyValue = selected.value
}
},
canSave() {
const name = this.keyName
const value = this.keyValue
if (value.length < 10 || /\s/.test(name) || /\s/.test(value)) return false
if (this.duplicateName) return false
const selected = this.keys.find((k) => k.name === this.selectedKeyName)
if (!selected) return true
return name !== selected.name || value !== selected.value
},
async save() {
const name = this.keyName.trim()
const value = this.keyValue.trim()
if (!this.canSave()) { showSnackbar("Invalid input or duplicate name.", "error"); return }
const updated = this.keys.filter((k) => k.name !== this.selectedKeyName)
updated.push({ name, value })
try {
this.keys = await api.saveTskKeys(updated)
this.selectKey(name)
showSnackbar("Saved key!")
} catch (e) {
showSnackbar(e?.data?.error || "Save failed...", "error")
}
},
async remove(name) {
if (!(await GalaxyConfirm({
title: "Confirm Delete",
message: `Are you sure you want to delete the key "${name}"?`,
confirmLabel: "Yes, Delete",
danger: true,
}))) return
try {
this.keys = await api.deleteTskKey(name)
this.keyName = ""
this.keyValue = ""
this.selectedKeyName = ""
showSnackbar("Deleted key!")
} catch (e) {
showSnackbar("Delete failed...", "error")
}
},
async apply() {
const selected = this.keys.find((k) => k.name === this.selectedKeyName)
if (!selected) { showSnackbar("Select a key from the list first", "error"); return }
try {
await api.tskKeySet(selected.name, selected.value)
showSnackbar("Key applied!")
} catch (e) {
showSnackbar("Apply failed...", "error")
}
},
},
template: `
<div>
<h2 style="margin-top:0;">Toyota Security Keys</h2>
<section class="gx-card">
<div style="padding: var(--sp-3); display:grid; gap:10px;">
<div v-if="loading" class="gx-loading">Loading keys...</div>
<template v-else>
<label class="gx-row__label" style="font-size:var(--fs-xs);">Select Key</label>
<select class="gx-field gx-field--full" :value="selectedKeyName" @change="selectKey($event.target.value)">
<option value="">-- Select a saved key --</option>
<option v-for="k in keys" :key="k.name" :value="k.name">{{ k.name }}</option>
</select>
<label class="gx-row__label" style="font-size:var(--fs-xs);">Key Name</label>
<input class="gx-field gx-field--full" v-model="keyName" @input="selectedKeyName = ''" placeholder="Enter key name..." autocomplete="off" />
<div v-if="duplicateName" class="gx-row__desc" style="color:var(--error);">A key with this name already exists.</div>
<label class="gx-row__label" style="font-size:var(--fs-xs);">Key Value</label>
<div style="display:flex; gap:8px;">
<input class="gx-field" style="flex:1;" v-model="keyValue" @input="selectedKeyName = ''" placeholder="Enter key value..." autocomplete="off" />
<button type="button" class="gx-icon-btn" title="Save key" :disabled="!canSave()" @click="save"><i class="bi bi-save"></i></button>
<button type="button" class="gx-icon-btn" title="Delete key" style="color:var(--error);" :disabled="!selectedKeyName" @click="remove(selectedKeyName)"><i class="bi bi-trash"></i></button>
</div>
<div style="display:flex; justify-content:flex-end; margin-top:8px;">
<button type="button" class="gx-btn" :disabled="!selectedKeyName" @click="apply"><i class="bi bi-check2-circle"></i> Apply Key</button>
</div>
</template>
</div>
</section>
</div>
`,
}
@@ -1,15 +1,45 @@
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
import { LateralTuningPanel } from "../components/LateralTuningPanel.js"
import { LongitudinalManeuvers } from "../components/LongitudinalManeuvers.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { Plots } from "./Plots.js"
import { TestingGround } from "./TestingGround.js"
import { useTabRouting } from "../composables.js"
const TABS = {
lateral: "Lateral Tuning",
long: "Long Maneuvers",
plots: "Plots",
testing: "Testing Ground",
}
export const Tuning = {
name: "Tuning",
components: { GalaxyEmbed },
components: { LateralTuningPanel, LongitudinalManeuvers, Plots, TestingGround, GalaxyTabs },
setup() {
return useTabRouting("/tuning", { lateral: "lateral", long: "long", plots: "plots", testing: "testing" })
},
data() { return { TABS } },
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" />
<h2 style="margin-top:0;">Tuning, Plots & Testing</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'lateral'">
<LateralTuningPanel />
</template>
<template v-else-if="tab === 'long'">
<LongitudinalManeuvers />
</template>
<template v-else-if="tab === 'plots'">
<Plots :embedded="true" />
</template>
<template v-else>
<TestingGround :embedded="true" />
</template>
</div>
`,
}
@@ -0,0 +1,441 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
const CANVAS_W = 640
const CANVAS_H = 480
const LEFT_LABEL = "LEFT SIDE OF VEHICLE - LEFT HERE"
const RIGHT_LABEL = "RIGHT SIDE OF VEHICLE - RIGHT HERE"
const BLUE = "#0d6efd"
const ORANGE = "#fd7e14"
function polyCenter(pts) {
if (!pts || !pts.length) return null
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length
return [cx, cy]
}
export const Vasm = {
name: "Vasm",
components: { GalaxySection },
props: { embedded: { type: Boolean, default: false } },
data() {
return {
snapshotLoading: false,
saving: false,
error: "",
success: "",
configSaved: false,
configExists: false,
annotating: null,
leftPoints: [],
rightPoints: [],
image: false,
leftActive: "-",
rightActive: "-",
leftConf: "-",
rightConf: "-",
loadedConfig: null,
BLUE,
ORANGE,
LEFT_LABEL,
RIGHT_LABEL,
}
},
computed: {
canSave() {
return this.leftPoints.length >= 3 || this.rightPoints.length >= 3
},
leftDone() { return this.leftPoints.length >= 3 },
rightDone() { return this.rightPoints.length >= 3 },
},
mounted() {
this._img = null
const canvas = this.$refs.canvas
if (canvas) { canvas.width = CANVAS_W; canvas.height = CANVAS_H }
this.poll = usePolling(() => this.pollStatus(), { interval: 3000 })
this.poll.start()
this.loadConfig()
this.loadSnapshot()
},
beforeUnmount() {
this.poll?.destroy()
if (this._img) this._img.src = ""
this._img = null
},
methods: {
clearMessages() { this.error = ""; this.success = "" },
setError(msg) { this.error = msg || ""; showSnackbar(msg || "Something went wrong.", "error") },
setSuccess(msg) { this.success = msg || "" },
canvas() { return this.$refs.canvas },
sizeCanvas() {
const canvas = this.canvas()
const img = this._img
if (!canvas || !img) return
const w = Math.min(img.naturalWidth, 1280)
canvas.width = w
canvas.height = Math.round(w * (img.naturalHeight / img.naturalWidth))
},
drawPolygon(ctx, points, fillColor, borderColor, label) {
if (points.length < 2) {
for (const pt of points) {
ctx.beginPath()
ctx.arc(pt[0], pt[1], 5, 0, Math.PI * 2)
ctx.fillStyle = borderColor
ctx.fill()
}
return
}
ctx.beginPath()
ctx.moveTo(points[0][0], points[0][1])
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1])
if (points.length >= 3) {
ctx.closePath()
ctx.fillStyle = fillColor
ctx.fill()
}
ctx.strokeStyle = borderColor
ctx.lineWidth = 2
ctx.stroke()
for (const pt of points) {
ctx.beginPath()
ctx.arc(pt[0], pt[1], 4, 0, Math.PI * 2)
ctx.fillStyle = "#fff"
ctx.fill()
ctx.strokeStyle = borderColor
ctx.lineWidth = 1.5
ctx.stroke()
}
if (points.length >= 3) {
const center = polyCenter(points)
if (center) {
ctx.fillStyle = "#fff"
ctx.font = "bold 14px monospace"
ctx.textAlign = "center"
ctx.fillText(label, center[0], center[1] + 5)
}
}
},
redraw() {
const canvas = this.canvas()
if (!canvas) return
this.sizeCanvas()
const ctx = canvas.getContext("2d")
ctx.clearRect(0, 0, canvas.width, canvas.height)
const img = this._img
if (!img) {
ctx.fillStyle = "#222"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = "#888"
ctx.font = "16px monospace"
ctx.textAlign = "center"
ctx.fillText("Loading camera snapshot...", canvas.width / 2, canvas.height / 2)
return
}
ctx.save()
ctx.translate(canvas.width, 0)
ctx.scale(-1, 1)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
ctx.restore()
ctx.font = "bold 14px sans-serif"
ctx.textAlign = "center"
ctx.fillStyle = "rgba(0, 0, 0, 0.65)"
ctx.fillRect(0, 0, canvas.width / 2, 34)
ctx.fillRect(canvas.width / 2, 0, canvas.width / 2, 34)
ctx.fillStyle = "#fff"
ctx.fillText(LEFT_LABEL, canvas.width / 4, 22)
ctx.fillText(RIGHT_LABEL, canvas.width * 3 / 4, 22)
this.drawPolygon(ctx, this.leftPoints, "rgba(13, 110, 253, 0.25)", BLUE, LEFT_LABEL)
this.drawPolygon(ctx, this.rightPoints, "rgba(253, 126, 20, 0.25)", ORANGE, RIGHT_LABEL)
},
canvasClick(e) {
if (!this.annotating || !this.image || !e) return
const canvas = this.canvas()
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * (canvas.width / rect.width))
const y = Math.round((e.clientY - rect.top) * (canvas.height / rect.height))
if (x < 0 || y < 0) return
const key = this.annotating === "left" ? "leftPoints" : "rightPoints"
const points = this[key].slice()
if (points.length >= 3) {
const first = points[0]
const dist = Math.sqrt((x - first[0]) ** 2 + (y - first[1]) ** 2)
if (dist < 12) { this.finishSide(); return }
}
points.push([x, y])
this[key] = points
this.redraw()
},
canvasRightClick() {
if (!this.annotating) return
const key = this.annotating === "left" ? "leftPoints" : "rightPoints"
const points = this[key].slice()
if (!points.length) return
points.pop()
this[key] = points
this.redraw()
},
startAnnotate(side) {
this.annotating = side
this.clearMessages()
this.redraw()
},
finishSide() {
if (!this.annotating) return
const side = this.annotating
const points = side === "left" ? this.leftPoints : this.rightPoints
if (points.length < 3) {
this.setError("Need at least 3 points to define a region.")
return
}
this.annotating = null
this.setSuccess(`${side === "left" ? "Left" : "Right"} window annotated (${points.length} points)!`)
this.redraw()
},
clearSide(side) {
if (side === "left") this.leftPoints = []
else this.rightPoints = []
this.annotating = null
this.redraw()
},
clearAll() {
this.leftPoints = []
this.rightPoints = []
this.annotating = null
this.configSaved = false
this.clearMessages()
this.redraw()
},
async saveConfig() {
if (this.leftPoints.length < 3 && this.rightPoints.length < 3) {
this.setError("Annotate at least one window with 3+ points.")
return
}
const img = this._img
const nativeW = img ? img.naturalWidth : 1920
const nativeH = img ? img.naturalHeight : 1080
const canvas = this.canvas()
const cw = canvas ? canvas.width : nativeW
const ch = canvas ? canvas.height : nativeH
const scalePoints = (pts) => pts.map(([x, y]) => [Math.round((cw - x) * nativeW / cw), Math.round(y * nativeH / ch)])
const config = { width: nativeW, height: nativeH }
config.poly_right = this.leftPoints.length >= 3 ? scalePoints(this.leftPoints) : []
config.poly_left = this.rightPoints.length >= 3 ? scalePoints(this.rightPoints) : []
this.saving = true
this.clearMessages()
try {
const data = await api.setVasmConfig(config)
this.configSaved = true
this.configExists = true
this.setSuccess(data?.message || "Annotation config saved! V-ASM is now enabled.")
showSnackbar("Annotation config saved! V-ASM is now enabled.")
await this.loadConfig()
} catch (e) {
this.setError(e?.data?.error || e?.message || "Failed to save")
} finally {
this.saving = false
}
},
async loadConfig() {
try {
const config = await api.getVasmConfig()
this.loadedConfig = config && typeof config === "object" && !Array.isArray(config) ? config : null
} catch (e) {
this.loadedConfig = null
}
this.applyConfigToCanvas()
},
applyConfigToCanvas() {
const config = this.loadedConfig
const canvas = this.canvas()
if (!config) {
this.leftPoints = []
this.rightPoints = []
this.configExists = false
this.redraw()
return
}
const cw = canvas ? (canvas.width || CANVAS_W) : CANVAS_W
const ch = canvas ? (canvas.height || CANVAS_H) : CANVAS_H
const img = this._img
const nativeW = img ? img.naturalWidth : (config.width || 1920)
const nativeH = img ? img.naturalHeight : (config.height || 1080)
const scaleX = cw / nativeW
const scaleY = ch / nativeH
const toCanvas = (pts) => pts.map(([x, y]) => [Math.round(cw - x * scaleX), Math.round(y * scaleY)])
const left = Array.isArray(config.poly_right) && config.poly_right.length >= 3 ? toCanvas(config.poly_right) : []
const right = Array.isArray(config.poly_left) && config.poly_left.length >= 3 ? toCanvas(config.poly_left) : []
this.leftPoints = left
this.rightPoints = right
this.configExists = Boolean(left.length || right.length)
this.redraw()
},
async deleteConfig() {
const confirmed = await GalaxyConfirm({
title: "Delete V-ASM Config?",
message: "This clears your window annotations and disables V-ASM.",
confirmLabel: "Delete Config",
danger: true,
})
if (!confirmed) return
this.saving = true
this.clearMessages()
try {
const data = await api.deleteVasmConfig()
this.leftPoints = []
this.rightPoints = []
this.annotating = null
this.configSaved = false
this.configExists = false
this.loadedConfig = null
this.setSuccess(data?.message || "Annotation config cleared.")
showSnackbar("Annotation config cleared.")
this.redraw()
} catch (e) {
this.setError(e?.data?.error || e?.message || "Failed to delete")
} finally {
this.saving = false
}
},
async loadSnapshot() {
this.clearMessages()
this.image = false
this.snapshotLoading = true
let url = null
try {
const blob = await api.getVasmSnapshotBlob()
url = URL.createObjectURL(blob)
const img = new Image()
img.onload = () => {
this._img = img
this.image = true
this.sizeCanvas()
this.setSuccess("Camera snapshot loaded. Click a window button above to start annotating.")
this.applyConfigToCanvas()
if (url) { URL.revokeObjectURL(url); url = null }
}
img.onerror = () => {
this.image = false
this.setError("Failed to decode image")
if (url) { URL.revokeObjectURL(url); url = null }
this.redraw()
}
img.src = url
} catch (e) {
this.image = false
this.setError(e?.message || "Failed to load snapshot")
this.redraw()
} finally {
this.snapshotLoading = false
}
},
async pollStatus() {
try {
const [la, ra, lc, rc] = await Promise.all([
api.getMemoryParam("VASMLeftActive"),
api.getMemoryParam("VASMRightActive"),
api.getMemoryParam("VASMLeftConfidence"),
api.getMemoryParam("VASMRightConfidence"),
])
this.leftActive = la === "1" ? "YES" : "no"
this.rightActive = ra === "1" ? "YES" : "no"
this.leftConf = Number.isFinite(parseFloat(lc)) ? parseFloat(lc).toFixed(3) : "-"
this.rightConf = Number.isFinite(parseFloat(rc)) ? parseFloat(rc).toFixed(3) : "-"
} catch (e) {
}
},
activeColor(side) {
return side === "left" ? BLUE : ORANGE
},
},
template: `
<div>
<h2 v-if="!embedded" style="margin-top:0;">Vision Adjacent Spot Monitoring (V-ASM)</h2>
<template v-if="!embedded">
<GalaxySection title="About V-ASM" icon="bi-info-circle">
<div style="padding: var(--sp-3); display:grid; gap:8px; color:var(--text-muted); line-height:1.5;">
<p style="margin:0;">Camera-based adjacent spot monitoring using the driver camera. It works alongside factory blind spot monitoring or standalone.</p>
<p style="margin:0;">Annotate window areas so V-ASM knows where to look. Submit edge cases and learn about the training pipeline in the form at <a href="https://github.com/prabhaavp/vasm-op" target="_blank" rel="noopener noreferrer">github.com/prabhaavp/vasm-op</a>.</p>
</div>
</GalaxySection>
<GalaxySection title="Tracing Guidelines" icon="bi-bullseye">
<ul style="margin:0; padding: var(--sp-3) var(--sp-3) var(--sp-3) calc(var(--sp-3) + 16px); color:var(--text-muted); line-height:1.6; display:grid; gap:6px;">
<li>The preview is mirrored: the LEFT side of the vehicle is always on the LEFT here; the RIGHT side is always on the RIGHT.</li>
<li>Trace the visible glass (front and rear side windows) seen by the driver camera. Mask as much window area as possible.</li>
<li>Exclude A-pillars, door frames, and interior. Include the side mirror if visible through the glass.</li>
<li>The B-pillar is fine to include as needed for a continuous mask. Be consistent left vs right.</li>
</ul>
</GalaxySection>
</template>
<div class="gx-alert" v-if="success" style="margin:0 0 12px;">{{ success }}</div>
<div class="gx-alert" v-if="error" style="margin:0 0 12px; color:var(--error);">{{ error }}</div>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-pencil-square"></i>
<span class="gx-section__title">Annotate Windows</span>
<span v-if="configSaved || configExists" class="gx-chip" style="background:var(--primary); color:var(--on-primary);">V-ASM enabled</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:12px;">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" :style="annotating === 'left' ? 'background:' + BLUE + '; color:#fff; border-color:' + BLUE + ';' : 'color:' + BLUE + '; border-color:' + BLUE + ';'" @click="startAnnotate('left')">
{{ annotating === 'left' ? 'Annotating Left Side of Vehicle - Left Here...' : 'Annotate Left Side of Vehicle - Left Here' }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" :style="annotating === 'right' ? 'background:' + ORANGE + '; color:#fff; border-color:' + ORANGE + ';' : 'color:' + ORANGE + '; border-color:' + ORANGE + ';'" @click="startAnnotate('right')">
{{ annotating === 'right' ? 'Annotating Right Side of Vehicle - Right Here...' : 'Annotate Right Side of Vehicle - Right Here' }}
</button>
<button v-if="annotating" type="button" class="gx-btn" @click="finishSide">Finish {{ annotating === 'left' ? 'Left' : 'Right' }}</button>
<button type="button" class="gx-btn" :disabled="saving || !canSave" @click="saveConfig"><i v-if="saving" class="bi bi-arrow-repeat gx-spin"></i><i v-else class="bi bi-check2-circle"></i> {{ saving ? 'Saving...' : 'Save Config' }}</button>
<button v-if="configExists" type="button" class="gx-btn" style="background:var(--error); color:var(--on-error);" :disabled="saving" @click="deleteConfig"><i class="bi bi-trash"></i> Delete Config</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="clearAll"><i class="bi bi-eraser"></i> Clear All</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="snapshotLoading" @click="loadSnapshot"><i v-if="snapshotLoading" class="bi bi-arrow-repeat gx-spin"></i><i v-else class="bi bi-camera"></i> {{ snapshotLoading ? 'Loading...' : 'Get a new Snapshot' }}</button>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<span class="gx-chip" :style="'border-color:' + BLUE + '; color:' + BLUE + ';'">Left: {{ leftPoints.length }} pt{{ leftPoints.length !== 1 ? 's' : '' }}<span v-if="leftDone"> </span>
<span v-if="leftPoints.length > 0" style="margin-left:6px; cursor:pointer; color:var(--error);" @click="clearSide('left')">×</span>
</span>
<span class="gx-chip" :style="'border-color:' + ORANGE + '; color:' + ORANGE + ';'">Right: {{ rightPoints.length }} pt{{ rightPoints.length !== 1 ? 's' : '' }}<span v-if="rightDone"> </span>
<span v-if="rightPoints.length > 0" style="margin-left:6px; cursor:pointer; color:var(--error);" @click="clearSide('right')">×</span>
</span>
</div>
<div v-if="annotating" class="gx-alert" :style="'color:' + activeColor(annotating) + ';'">
{{ annotating === 'left' ? LEFT_LABEL : RIGHT_LABEL }} Click the canvas to place points around the visible glass. Click near the first point to close the polygon. Right-click to undo the last point.
</div>
<div v-else-if="!image" class="gx-row__desc">Loading camera snapshot... The snapshot will load automatically.</div>
<div v-else class="gx-row__desc">Idle click a window button above to configure your active canvas boundaries.</div>
<canvas ref="canvas" id="v-asm-canvas" style="width:100%; height:auto; background:#222; border-radius:8px; display:block; touch-action:none;" @click="canvasClick" @contextmenu.prevent="canvasRightClick"></canvas>
</div>
</section>
<GalaxySection v-if="!embedded" title="Sensitivity Settings" icon="bi-sliders">
<ul style="margin:0; padding: var(--sp-3) var(--sp-3) var(--sp-3) calc(var(--sp-3) + 16px); color:var(--text-muted); line-height:1.6; display:grid; gap:6px;">
<li>Confidence Threshold: minimum confidence for a detection (higher means fewer false positives).</li>
<li>Smoothing Duration: time constant for signal smoothing (higher means less flickering).</li>
<li>Adjust these in Toggles Lateral once enabled.</li>
</ul>
</GalaxySection>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-activity"></i>
<span class="gx-section__title">Current Status</span>
</div>
<div style="padding: var(--sp-3); display:grid; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:8px;">
<div class="gx-card" style="padding: 8px 10px;"><div class="gx-row__label" style="font-size:var(--fs-xs);">Left Active</div><div class="gx-row__value" :style="leftActive === 'YES' ? 'color:var(--primary);' : 'color:var(--text-muted);'">{{ leftActive }}</div></div>
<div class="gx-card" style="padding: 8px 10px;"><div class="gx-row__label" style="font-size:var(--fs-xs);">Right Active</div><div class="gx-row__value" :style="rightActive === 'YES' ? 'color:' + ORANGE + ';' : 'color:var(--text-muted);'">{{ rightActive }}</div></div>
<div class="gx-card" style="padding: 8px 10px;"><div class="gx-row__label" style="font-size:var(--fs-xs);">Left Confidence</div><div class="gx-row__value">{{ leftConf }}</div></div>
<div class="gx-card" style="padding: 8px 10px;"><div class="gx-row__label" style="font-size:var(--fs-xs);">Right Confidence</div><div class="gx-row__value">{{ rightConf }}</div></div>
</div>
</section>
</div>
`,
}
@@ -3,22 +3,37 @@ import { navigate, toolHref } from "../store.js"
import { WheelControls } from "../components/WheelControls.js"
import { BluetoothPanel } from "../components/BluetoothPanel.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { useTabRouting } from "../composables.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" },
]
const TABS = {
controllers: "Controllers",
bluetooth: "Bluetooth",
features: "Vehicle Features",
}
export const Vehicle = {
name: "Vehicle",
components: { WheelControls, BluetoothPanel, GalaxySection },
components: { WheelControls, BluetoothPanel, GalaxySection, GalaxyTabs },
data() {
return {
TABS,
features: FEATURES,
featureStatus: {},
busy: "",
}
},
setup() {
return useTabRouting("/vehicle", { controllers: "controllers", bluetooth: "bluetooth", features: "features" })
},
computed: {
featureList() { return this.features },
},
methods: {
statusOf(key) { return this.featureStatus[key] || "untested" },
async openFeature(f) {
@@ -47,33 +62,37 @@ export const Vehicle = {
},
},
template: `
<div>
<div class="gx-view">
<h2 style="margin-top:0;">Vehicle Controls</h2>
<GalaxySection title="Controllers" icon="bi-controller">
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'controllers'">
<WheelControls />
</GalaxySection>
</template>
<GalaxySection title="Bluetooth" icon="bi-bluetooth">
<template v-else-if="tab === 'bluetooth'">
<BluetoothPanel />
</GalaxySection>
</template>
<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>
<template v-else>
<GalaxySection title="Vehicle Features" icon="bi-check2-square">
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<button v-for="f in featureList" :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>
</template>
</div>
`,
}
@@ -105,9 +105,10 @@ 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/Logs.js": ["getErrorLogs", "tmuxSnapshot"],
"js/components/TroubleshootPanel.js": ["getTroubleshoot", "resetTroubleshootSection", "GalaxyConfirm"],
"js/views/Tuning.js": ["LateralTuningPanel", "LongitudinalManeuvers"],
"js/views/Navigation.js": ["getNavigation", "setNavigation", "MapsPanel", "NavigationKeysPanel"],
"js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"],
"js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"],
"js/components/WheelControls.js": ["getWheelControlsStatus"],
@@ -126,7 +127,6 @@ 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}"
@@ -139,14 +139,19 @@ def test_ui_routes_ported_views_natively_no_classic_fallback():
# 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
assert "/cameras" in tools, "Tools grid should route the camera hub natively"
assert "/manage_v_asm" not in tools and "/manage_pip_sidecam" not 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
home = _read("js/views/Home.js")
assert "GalaxyEmbed" not in home and 'src="/classic"' not in home
assert "api.getStats()" in home and "keepRefreshing" in home and "usePolling" in home
for section in ["Last drive", "This week", "Recent drives", "Personal records",
"Most used models", "Storage", "Vitals", "Software", "Your driving", "Your device"]:
assert section in home, f"Home dashboard should include {section!r}"
assert "setDriveStats" in _read("js/api.js")
assert "fetch(" not in home.replace("api.", ""), "Home should not use raw fetch()"
# 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
@@ -200,13 +205,9 @@ def test_ui_centralizes_api_and_uses_composables():
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
assert "GalaxyEmbed" not in tuning and 'src="/tuning"' not in tuning, "Tuning must be native, not a classic embed"
assert "LateralTuningPanel" in tuning and "LongitudinalManeuvers" in tuning
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
@@ -264,7 +265,7 @@ def test_ui_has_bottom_navigation_and_drawer():
assert "goBack" in shell or "back()" in shell
assert "gx-drawer" in shell
assert "gx-appbar" in shell
assert "Search settings" in shell
assert "Search toggles" in shell
def test_ui_search_visible_on_mobile_and_content_full_width():
@@ -341,6 +342,130 @@ def test_ui_manifest_is_valid_pwa_manifest():
assert manifest["start_url"] == "/mobile/"
def test_ui_ported_classic_tools_native_no_embed():
app = _read("js/app.js")
store = _read("js/store.js")
api = _read("js/api.js")
assert "Doors" in app and "Galaxy" in app and "Tsk" in app
for route in ["/manage_doors", "/galaxy", "/manage_tsk"]:
assert route in app, f"app.js should register {route}"
assert route in store, f"store NATIVE_ROOTS should include {route}"
doors = _read("js/views/Doors.js")
galaxy = _read("js/views/Galaxy.js")
tsk = _read("js/views/Tsk.js")
assert "GalaxyEmbed" not in doors and "fetch(" not in doors
assert "api.postAction" in doors and "showSnackbar" in doors
assert "GalaxyEmbed" not in galaxy and "fetch(" not in galaxy
assert "api.getGalaxyStatus" in galaxy and "api.galaxyPair" in galaxy and "api.galaxyUnpair" in galaxy
assert "GalaxyConfirm" in galaxy # destructive unpair is confirmed
assert "GalaxyEmbed" not in tsk and "fetch(" not in tsk
assert "api.getTskKeys" in tsk and "api.saveTskKeys" in tsk and "api.deleteTskKey" in tsk and "api.tskKeySet" in tsk
# New shared API surface added for the ported pages.
for method in ["getGalaxyStatus", "galaxyPair", "galaxyUnpair",
"getSpeedLimitsStatus", "processSpeedLimits",
"getTskKeys", "saveTskKeys", "deleteTskKey", "tskKeySet",
"resetTroubleshootSection"]:
assert method in api, f"api.js should expose {method}"
# Troubleshoot is now a native panel inside the Logs view (no embed).
logs = _read("js/views/Logs.js")
assert "GalaxyEmbed" not in logs
assert "TroubleshootPanel" in logs
panel = _read("js/components/TroubleshootPanel.js")
assert "getTroubleshoot" in panel and "resetTroubleshootSection" in panel
assert "fetch(" not in panel
# Speed limits panel is native and reused by the Navigation speeds tab.
nav = _read("js/views/Navigation.js")
assert "SpeedLimitsPanel" in nav
nav_speed = nav
assert 'src="/download_speed_limits"' not in nav_speed
panel = _read("js/components/SpeedLimitsPanel.js")
assert "getSpeedLimitsStatus" in panel and "processSpeedLimits" in panel
assert "usePolling" in panel
assert "fetch(" not in panel
def test_ui_all_remaining_classic_tools_native_no_embed():
app = _read("js/app.js")
store = _read("js/store.js")
api = _read("js/api.js")
# Standalone native views + their routes.
native = {
"/sentry": "Sentry",
"/manage_models": "ModelManager",
"/plots": "Plots",
"/testing_ground": "TestingGround",
"/theme_maker": "ThemeMaker",
}
for route, view in native.items():
assert view in app, f"app.js should register {view}"
assert route in app, f"app.js should resolve {route}"
assert route in store, f"store NATIVE_ROOTS should include {route}"
src = _read(f"js/views/{view}.js")
assert src, f"missing view: {view}"
assert "GalaxyEmbed" not in src and "fetch(" not in src, f"{view} should be native with no raw fetch"
# Navigation maps + App Keys and Tuning lateral are native tabs now.
nav = _read("js/views/Navigation.js")
assert "GalaxyEmbed" not in nav and "MapsPanel" in nav and "NavigationKeysPanel" in nav
tuning = _read("js/views/Tuning.js")
assert "GalaxyEmbed" not in tuning and "LateralTuningPanel" in tuning
assert _read("js/components/MapsPanel.js") and _read("js/components/NavigationKeysPanel.js")
assert _read("js/components/LateralTuningPanel.js")
# Shared API surface added for the second batch of ported pages.
for method in ["selectTestingGround",
"getSentryStatus", "getSentryEvents", "deleteSentryEvent", "sentryPushSubscribe",
"getModelStatus", "startModelDownload", "downloadAllModels", "deleteModel", "saveModelPreferences",
"getPlotsLive",
"getGalaxySession", "deleteNavigationKey",
"getThemeList", "saveTheme", "applyTheme", "deleteTheme", "downloadTheme",
"getFlmStatus", "flmAnalyze", "flmApplyTrial", "flmRevertTrial", "flmSaveFeedback",
"flmSaveTune", "flmApplySavedTune", "flmRenameSavedTune", "flmDeleteSavedTune", "flmSubmitTune"]:
assert method in api, f"api.js should expose {method}"
# Panels used by the native tabs expose no raw fetch.
for rel in ["js/components/MapsPanel.js", "js/components/NavigationKeysPanel.js",
"js/components/LateralTuningPanel.js"]:
assert "fetch(" not in _read(rel), f"{rel} should not use raw fetch()"
def test_ui_cameras_hub_vasm_and_pip_native_no_embed():
app = _read("js/app.js")
store = _read("js/store.js")
api = _read("js/api.js")
cameras = _read("js/views/Cameras.js")
assert cameras
assert "/cameras" in app and "Cameras" in app, "app.js should register the camera hub"
assert "/cameras" in store, "store NATIVE_ROOTS should include /cameras"
assert "GalaxyEmbed" not in cameras and "fetch(" not in cameras
assert "Vasm" in cameras and "Pip" in cameras, "camera hub should embed V-ASM and PiP"
assert "GalaxyTabs" in cameras
# Removed standalone pages are no longer routed or listed as native roots.
for route in ["/manage_v_asm", "/manage_pip_sidecam"]:
assert route not in app, f"{route} standalone page should be removed from app.js"
assert route not in store, f"{route} should be removed from NATIVE_ROOTS"
vasm = _read("js/views/Vasm.js")
assert "api.getVasmSnapshotBlob" in vasm and "api.deleteVasmConfig" in vasm and "api.getMemoryParam" in vasm
assert "GalaxyConfirm" in vasm # destructive delete is confirmed
assert "GalaxyEmbed" not in vasm and "fetch(" not in vasm
pip = _read("js/views/Pip.js")
assert "api.pipSnapshotSource" in pip and "api.deletePipConfig" in pip and "api.setPipConfig" in pip
assert "GalaxyEmbed" not in pip and "fetch(" not in pip
for method in ["getVasmSnapshotBlob", "deleteVasmConfig", "getMemoryParam",
"pipSnapshotSource", "deletePipConfig"]:
assert method in api, f"api.js should expose {method}"
def _node_exe():
candidates = [
shutil.which("node"),