mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-05 07:33:44 +08:00
Compare commits
1 Commits
TickleMeELMo
...
Dom
| Author | SHA1 | Date | |
|---|---|---|---|
| f55ad9162d |
Executable
+49
@@ -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--tonal:hover { background: rgba(157, 114, 255, 0.28); }
|
||||||
.gx-btn--block { width: 100%; }
|
.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 {
|
.gx-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
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 {
|
.gx-tile {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--surface-container);
|
background: var(--surface-container);
|
||||||
@@ -961,6 +1030,23 @@ input[type="color"].gx-color {
|
|||||||
color: var(--error);
|
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 {
|
.gx-alert {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
backdrop-filter: var(--glass-blur);
|
backdrop-filter: var(--glass-blur);
|
||||||
@@ -985,6 +1071,14 @@ input[type="color"].gx-color {
|
|||||||
border-left-color: var(--warning);
|
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__icon { font-size: 1.4rem; color: var(--primary); }
|
||||||
.gx-alert__body { flex: 1 1 220px; }
|
.gx-alert__body { flex: 1 1 220px; }
|
||||||
.gx-alert__body strong { display: block; font-weight: var(--fw-bold); }
|
.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);
|
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 { position: relative; }
|
||||||
|
|
||||||
.gx-tree-node--child {
|
.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; }
|
.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) {
|
@media (max-width: 767px) {
|
||||||
.liquid-glass-nav { display: flex; }
|
.liquid-glass-nav { display: flex; }
|
||||||
.gx-menu-btn { display: none; }
|
.gx-menu-btn { display: none; }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-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/vendor/bootstrap-icons/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/mobile/css/material.css">
|
<link rel="stylesheet" href="/assets/mobile/css/material.css">
|
||||||
|
<link rel="stylesheet" href="/assets/mobile/css/home.css">
|
||||||
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const LAYOUT_URL = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1"
|
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(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data?.error || data?.message || res.statusText || "Request failed")
|
const err = new Error(data?.error || data?.message || res.statusText || "Request failed")
|
||||||
@@ -10,85 +10,65 @@ async function handle(res) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
function initFor({ method = "GET", data, form, headers, cache, signal } = {}) {
|
||||||
async postAction(endpoint) {
|
const init = { method }
|
||||||
const res = await fetch(endpoint, { method: "POST" })
|
if (cache) init.cache = cache
|
||||||
return handle(res)
|
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) {
|
function request(url, opts) {
|
||||||
const res = await fetch(endpoint)
|
return fetch(url, initFor(opts)).then(parse)
|
||||||
return handle(res)
|
}
|
||||||
},
|
|
||||||
|
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() {
|
async getLayout() {
|
||||||
const res = await fetch(LAYOUT_URL, { cache: "no-store" })
|
const data = await request(LAYOUT_URL, { cache: "no-store" })
|
||||||
const data = await handle(res)
|
|
||||||
return (data || [])
|
return (data || [])
|
||||||
.map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") }))
|
.map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") }))
|
||||||
.filter((section) => (section.params || []).length > 0)
|
.filter((section) => (section.params || []).length > 0)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getParams() {
|
getParams() { return request("/api/params/all") },
|
||||||
const res = await fetch("/api/params/all")
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getDefaults() {
|
async getDefaults() {
|
||||||
const res = await fetch("/api/params/defaults")
|
const res = await fetch("/api/params/defaults")
|
||||||
return res.ok ? handle(res) : {}
|
return res.ok ? parse(res) : {}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateParam({ key, value, label }) {
|
updateParam({ key, value, label }) {
|
||||||
const body = { key, value }
|
const data = { key, value }
|
||||||
if (label) body.label = label
|
if (label) data.label = label
|
||||||
const res = await fetch("/api/params", {
|
return request("/api/params", { method: "PUT", data })
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
return handle(res)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getFlmWorkspace() {
|
getFlmWorkspace() { return requestOk("/api/flm/workspace", { cache: "no-store" }) },
|
||||||
const res = await fetch("/api/flm/workspace", { cache: "no-store" })
|
getFavoritesSlots() { return request("/api/favorites/slots", { cache: "no-store" }) },
|
||||||
return res.ok ? handle(res) : null
|
saveFavoritesSlots(slots) { return request("/api/favorites/slots", { method: "PUT", data: { slots } }) },
|
||||||
},
|
activateFavoriteAction(key) { return request("/api/favorites/action", { method: "POST", data: { key } }) },
|
||||||
|
|
||||||
async getFavoritesSlots() {
|
getDeviceStatus() { return requestOk("/api/device/status") },
|
||||||
const res = await fetch("/api/favorites/slots", { cache: "no-store" })
|
getStats() { return requestOk("/api/stats") },
|
||||||
return handle(res)
|
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 } = {}) {
|
async getRoutesStream({ onProgress, onRoutes, signal } = {}) {
|
||||||
const res = await fetch("/api/routes", { signal })
|
const res = await fetch("/api/routes", { signal })
|
||||||
if (!res.ok || !res.body) throw new Error(`Route request failed (${res.status})`)
|
if (!res.ok || !res.body) throw new Error(`Route request failed (${res.status})`)
|
||||||
@@ -113,253 +93,66 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getRoute(name) {
|
getRoute(name) { return request(`/api/routes/${encodeURIComponent(name)}`) },
|
||||||
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`)
|
deleteRoute(name) { return request(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" }) },
|
||||||
return handle(res)
|
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) {
|
getScreenRecordings() { return request("/api/screen_recordings/list") },
|
||||||
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" })
|
deleteScreenRecording(filename) { return request(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" }) },
|
||||||
return handle(res)
|
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) {
|
getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) },
|
||||||
const res = await fetch("/api/routes/rename", {
|
getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) },
|
||||||
method: "POST",
|
deleteErrorLog(filename) { return delOk(`/api/error_logs/${encodeURIComponent(filename)}`) },
|
||||||
headers: { "Content-Type": "application/json" },
|
deleteAllErrorLogs() { return delOk("/api/error_logs/delete_all") },
|
||||||
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
|
|
||||||
},
|
|
||||||
|
|
||||||
|
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) {
|
async renameTmuxLog(oldName, newName) {
|
||||||
const res = await fetch(`/api/tmux_log/rename/${encodeURIComponent(oldName)}/${encodeURIComponent(newName)}`, { method: "PUT" })
|
const res = await fetch(`/api/tmux_log/rename/${encodeURIComponent(oldName)}/${encodeURIComponent(newName)}`, { method: "PUT" })
|
||||||
return res.ok
|
return res.ok
|
||||||
},
|
},
|
||||||
|
|
||||||
async runTroubleshoot() {
|
runTroubleshoot() { return request("/api/troubleshoot", { method: "POST" }) },
|
||||||
const res = await fetch("/api/troubleshoot", { method: "POST" })
|
getTroubleshoot() { return requestOk("/api/troubleshoot") },
|
||||||
return handle(res)
|
resetTroubleshoot() { return postOk("/api/troubleshoot/reset") },
|
||||||
},
|
resetTroubleshootSection(sectionId) { return request("/api/troubleshoot/reset", { method: "POST", data: { sectionId } }) },
|
||||||
|
|
||||||
async getTroubleshoot() {
|
getWheelControlsStatus() { return request("/api/wheel-controls/status", { cache: "no-store" }) },
|
||||||
const res = await fetch("/api/troubleshoot")
|
wheelControlsOp(operation, body = {}) { return request(`/api/wheel-controls/${operation}`, { method: "POST", data: body }) },
|
||||||
return res.ok ? handle(res) : null
|
|
||||||
},
|
|
||||||
|
|
||||||
async resetTroubleshoot() {
|
getBluetoothStatus() { return request("/api/bluetooth/status") },
|
||||||
const res = await fetch("/api/troubleshoot/reset", { method: "POST" })
|
bluetoothOp(operation, body = {}) { return request(`/api/bluetooth/${operation}`, { method: "POST", data: body }) },
|
||||||
return res.ok
|
|
||||||
},
|
|
||||||
|
|
||||||
|
carFeaturesCheck(tool = "") {
|
||||||
async getWheelControlsStatus() {
|
|
||||||
const res = await fetch("/api/wheel-controls/status", { cache: "no-store" })
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async wheelControlsOp(operation, body = {}) {
|
|
||||||
const res = await fetch(`/api/wheel-controls/${operation}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getBluetoothStatus() {
|
|
||||||
const res = await fetch("/api/bluetooth/status")
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async bluetoothOp(operation, body = {}) {
|
|
||||||
const res = await fetch(`/api/bluetooth/${operation}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async carFeaturesCheck(tool = "") {
|
|
||||||
const query = tool ? `?tool=${encodeURIComponent(tool)}` : ""
|
const query = tool ? `?tool=${encodeURIComponent(tool)}` : ""
|
||||||
const res = await fetch(`/api/car_features_check${query}`)
|
return requestOk(`/api/car_features_check${query}`)
|
||||||
return res.ok ? handle(res) : null
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
lateralManeuvers(action) { return request(`/api/lateral_maneuvers/${action}`, { method: "POST" }) },
|
||||||
async lateralManeuvers(action) {
|
lateralManeuversStatus() { return request("/api/lateral_maneuvers/status") },
|
||||||
const res = await fetch(`/api/lateral_maneuvers/${action}`, { method: "POST" })
|
longitudinalManeuvers(action) { return request(`/api/longitudinal_maneuvers/${action}`, { method: "POST" }) },
|
||||||
return handle(res)
|
longitudinalManeuversStatus() { return request("/api/longitudinal_maneuvers/status") },
|
||||||
},
|
|
||||||
|
|
||||||
async lateralManeuversStatus() {
|
getMapsStatus() { return request("/api/maps/status") },
|
||||||
const res = await fetch("/api/lateral_maneuvers/status")
|
getMapsCatalog() { return request("/api/maps/catalog") },
|
||||||
return handle(res)
|
mapsOp(operation, body = {}) { return request(`/api/maps/${operation}`, { method: "POST", data: body }) },
|
||||||
},
|
|
||||||
|
|
||||||
async longitudinalManeuvers(action) {
|
getNavigation() { return request("/api/navigation") },
|
||||||
const res = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
|
setNavigation(body) { return request("/api/navigation", { method: "POST", data: body }) },
|
||||||
return handle(res)
|
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() {
|
async backupToggles() {
|
||||||
const res = await fetch("/api/toggles/backup", { method: "POST" })
|
const res = await fetch("/api/toggles/backup", { method: "POST" })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -368,107 +161,155 @@ export const api = {
|
|||||||
}
|
}
|
||||||
return res.blob()
|
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) {
|
getUpdateBranches() { return request("/api/update/branches") },
|
||||||
const res = await fetch("/api/toggles/restore", {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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() {
|
async galaxyUnpair() {
|
||||||
const res = await fetch("/api/toggles/reset_default", { method: "POST" })
|
const res = await fetch("/api/galaxy/unpair", { method: "POST" })
|
||||||
return handle(res)
|
const data = await parse(res).catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data?.error || data?.message || "Unpairing failed.")
|
||||||
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUpdateBranches() {
|
selectTestingGround(body) { return request("/api/testing_grounds/select", { method: "POST", data: body }) },
|
||||||
const res = await fetch("/api/update/branches")
|
|
||||||
return handle(res)
|
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() {
|
getFlmStatus() { return requestOk("/api/flm/status", { cache: "no-store" }) },
|
||||||
const res = await fetch("/api/update/branch")
|
getFlmReport(reportId) { return requestOk(`/api/flm/report/${encodeURIComponent(reportId)}`, { cache: "no-store" }) },
|
||||||
return handle(res)
|
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) {
|
deleteVasmConfig() { return request("/api/v_asm/config", { method: "DELETE" }) },
|
||||||
const res = await fetch("/api/update/branch", {
|
|
||||||
method: "POST",
|
async getMemoryParam(key) {
|
||||||
headers: { "Content-Type": "application/json" },
|
const res = await fetch(`/api/params_memory?key=${encodeURIComponent(key)}`, { cache: "no-store" })
|
||||||
body: JSON.stringify({ branch }),
|
if (!res.ok) {
|
||||||
})
|
const data = await res.json().catch(() => ({}))
|
||||||
return handle(res)
|
throw new Error(data?.error || res.statusText || "Request failed")
|
||||||
|
}
|
||||||
|
return res.text()
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateFast() {
|
deletePipConfig() { return request("/api/pip_preview/config", { method: "DELETE" }) },
|
||||||
const res = await fetch("/api/update/fast", { method: "POST" })
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUpdateFastStatus() {
|
async pipSnapshotSource() {
|
||||||
const res = await fetch("/api/update/fast/status")
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateRecover() {
|
|
||||||
const res = await fetch("/api/update/recover", { method: "POST" })
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateRollback() {
|
|
||||||
const res = await fetch("/api/update/rollback", { method: "POST" })
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async factoryReset() {
|
|
||||||
const res = await fetch("/api/update/factory_reset", { method: "POST" })
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAgnosStatus() {
|
|
||||||
const res = await fetch("/api/update/agnos_status")
|
|
||||||
return res.ok ? handle(res) : null
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
async getVasmConfig() {
|
|
||||||
const res = await fetch("/api/v_asm/config")
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async setVasmConfig(body) {
|
|
||||||
const res = await fetch("/api/v_asm/config", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async vasmSnapshot() {
|
|
||||||
const res = await fetch("/api/v_asm/snapshot")
|
|
||||||
return res.ok ? handle(res) : null
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPipConfig() {
|
|
||||||
const res = await fetch("/api/pip_preview/config")
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async setPipConfig(body) {
|
|
||||||
const res = await fetch("/api/pip_preview/config", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
return handle(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async pipSnapshot() {
|
|
||||||
const res = await fetch("/api/pip_preview/snapshot")
|
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 { Vehicle } from "./views/Vehicle.js"
|
||||||
import { SystemTools } from "./views/SystemTools.js"
|
import { SystemTools } from "./views/SystemTools.js"
|
||||||
import { ToolEmbed } from "./views/ToolEmbed.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 { store, initRouter, navigate } from "./store.js"
|
||||||
import { showSnackbar } from "./api.js"
|
import { showSnackbar } from "./api.js"
|
||||||
|
|
||||||
@@ -35,6 +44,15 @@ const VIEWS = {
|
|||||||
"/vehicle": Vehicle,
|
"/vehicle": Vehicle,
|
||||||
"/system": SystemTools,
|
"/system": SystemTools,
|
||||||
"/embed": ToolEmbed,
|
"/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) {
|
function resolveView(path) {
|
||||||
|
|||||||
@@ -7,25 +7,22 @@ const NAV = {
|
|||||||
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
|
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
|
||||||
],
|
],
|
||||||
tools: [
|
tools: [
|
||||||
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" },
|
{ name: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video" },
|
||||||
{ name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right" },
|
|
||||||
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
|
|
||||||
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" },
|
|
||||||
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box" },
|
|
||||||
{ name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video" },
|
|
||||||
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
|
|
||||||
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
|
{ name: "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: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
|
||||||
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
|
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
|
||||||
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
|
{ 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: "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 = [
|
const BOTTOM_NAV = [
|
||||||
{ name: "Home", link: "/", icon: "bi-house-fill" },
|
{ 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: "Tools", link: "/tools", icon: "bi-tools" },
|
||||||
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
|
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
|
||||||
]
|
]
|
||||||
@@ -104,8 +101,8 @@ export const AppShell = {
|
|||||||
</button>
|
</button>
|
||||||
<span class="gx-appbar__title">Galaxy</span>
|
<span class="gx-appbar__title">Galaxy</span>
|
||||||
<div class="gx-searchwrap">
|
<div class="gx-searchwrap">
|
||||||
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search settings..."
|
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search toggles..."
|
||||||
v-model="search" aria-label="Search settings" />
|
v-model="search" aria-label="Search toggles" />
|
||||||
<button v-if="search" type="button" class="gx-search-clear" aria-label="Clear search" @click="clearSearch">
|
<button v-if="search" type="button" class="gx-search-clear" aria-label="Clear search" @click="clearSearch">
|
||||||
<i class="bi bi-x"></i>
|
<i class="bi bi-x"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { api, showSnackbar } from "../api.js"
|
import { api, showSnackbar } from "../api.js"
|
||||||
import { usePolling } from "../composables.js"
|
import { usePolling } from "../composables.js"
|
||||||
|
import { GxNotice } from "./GxNotice.js"
|
||||||
|
|
||||||
function address(device) { return String(device.address || "").toUpperCase() }
|
function address(device) { return String(device.address || "").toUpperCase() }
|
||||||
|
|
||||||
export const BluetoothPanel = {
|
export const BluetoothPanel = {
|
||||||
name: "BluetoothPanel",
|
name: "BluetoothPanel",
|
||||||
|
components: { GxNotice },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false,
|
loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false,
|
||||||
@@ -84,8 +86,8 @@ export const BluetoothPanel = {
|
|||||||
<span>Bluetooth {{ enabled ? 'On' : 'Off' }}</span>
|
<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>
|
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!available || offroadDisabled()" @click="request('power', { enabled: !enabled })">{{ enabled ? 'Turn Off' : 'Turn On' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!offroad" style="color:var(--text-muted);">Scanning, pairing, and forgetting devices are available offroad only.</p>
|
<GxNotice v-if="!offroad" text="Scanning, pairing, and forgetting devices are available offroad only." style="margin:0 0 var(--sp-2);" />
|
||||||
<p v-if="error" style="color:var(--error);">{{ error }}</p>
|
<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 v-if="prompt" class="gx-card" style="margin:12px 0; background:var(--surface-variant);">
|
||||||
<div class="gx-section__header"><i class="bi bi-shield-check"></i><span class="gx-section__title">Pairing request · {{ prompt.name }}</span></div>
|
<div 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.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" 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.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
// Single source of truth for page-in-page embeds of the classic Galaxy SPA.
|
// Single source of truth for page-in-page embeds of the classic Galaxy SPA.
|
||||||
// Every embed (ToolEmbed, Home dashboard, Tuning, Navigation maps/keys/speeds,
|
// Ideally, implement new tools Vue based instead of Arrow.
|
||||||
// SystemTools toggles, Logs troubleshoot) renders through this component so the
|
|
||||||
// classic page always gets: an `embedded=1` marker, sidebar-hide styles, and
|
|
||||||
// optional navigation forwarding back to the mobile app.
|
|
||||||
export const GalaxyEmbed = {
|
export const GalaxyEmbed = {
|
||||||
name: "GalaxyEmbed",
|
name: "GalaxyEmbed",
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -38,8 +38,16 @@ export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger
|
|||||||
const host = document.createElement("div")
|
const host = document.createElement("div")
|
||||||
document.body.appendChild(host)
|
document.body.appendChild(host)
|
||||||
const { createApp, h } = window.__galaxyVue
|
const { createApp, h } = window.__galaxyVue
|
||||||
let instance
|
let app
|
||||||
const app = createApp({
|
let settled = false
|
||||||
|
const finish = (value) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(value)
|
||||||
|
try { app?.unmount?.() } catch (e) {}
|
||||||
|
host.remove()
|
||||||
|
}
|
||||||
|
app = createApp({
|
||||||
render() {
|
render() {
|
||||||
return h(GalaxyModal, {
|
return h(GalaxyModal, {
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
@@ -47,17 +55,12 @@ export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger
|
|||||||
message,
|
message,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
danger,
|
danger,
|
||||||
"onUpdate:modelValue": (v) => { if (!v) teardown() },
|
"onUpdate:modelValue": (v) => { if (!v) finish(false) },
|
||||||
onConfirm: () => { teardown(); resolve(true) },
|
onConfirm: () => finish(true),
|
||||||
onCancel: () => { teardown(); resolve(false) },
|
onCancel: () => finish(false),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const teardown = () => {
|
app.mount(host)
|
||||||
app.unmount()
|
|
||||||
host.remove()
|
|
||||||
resolve(false)
|
|
||||||
}
|
|
||||||
instance = app.mount(host)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,25 @@ export const GalaxySection = {
|
|||||||
icon: { type: String, default: "bi-toggles" },
|
icon: { type: String, default: "bi-toggles" },
|
||||||
count: { type: [Number, String], default: "" },
|
count: { type: [Number, String], default: "" },
|
||||||
defaultOpen: { type: Boolean, default: true },
|
defaultOpen: { type: Boolean, default: true },
|
||||||
|
collapsible: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
data() { return { open: this.defaultOpen } },
|
data() { return { open: this.defaultOpen } },
|
||||||
template: `
|
template: `
|
||||||
<section class="gx-card">
|
<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>
|
<i class="bi" :class="icon"></i>
|
||||||
<span class="gx-section__title">{{ title }}</span>
|
<span class="gx-section__title">{{ title }}</span>
|
||||||
<span v-if="count !== ''" class="gx-section__count">{{ count }}</span>
|
<span v-if="count !== ''" class="gx-section__count">{{ count }}</span>
|
||||||
<i class="bi bi-chevron-down gx-chevron" :class="{ open }"></i>
|
<i class="bi bi-chevron-down gx-chevron" :class="{ open }"></i>
|
||||||
</div>
|
</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">
|
<transition name="gx-collapse">
|
||||||
<div v-show="open" class="gx-section__body">
|
<div v-show="!collapsible || open" class="gx-section__body">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</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 & 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) }} → {{ 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) }} → {{ 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>
|
<p style="color: var(--text-muted); line-height:1.5;">{{ intro }}</p>
|
||||||
<div style="display:flex; gap:8px; margin: 12px 0;">
|
<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" :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>
|
||||||
<div v-if="loading" class="gx-loading">Loading status...</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;">
|
<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 & 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 { api } from "../api.js"
|
||||||
import { usePolling } from "../composables.js"
|
import { usePolling } from "../composables.js"
|
||||||
|
import { GxNotice } from "./GxNotice.js"
|
||||||
|
|
||||||
const FAVORITE_SLOT_COUNT = 3
|
const FAVORITE_SLOT_COUNT = 3
|
||||||
|
|
||||||
export const WheelControls = {
|
export const WheelControls = {
|
||||||
name: "WheelControls",
|
name: "WheelControls",
|
||||||
|
components: { GxNotice },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true, busy: "", available: false, offroad: false, learning: false,
|
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))
|
const seconds = Math.max(0, Math.ceil(this.remainingSeconds))
|
||||||
return seconds > 0 ? `Listening (${seconds}s)` : "Listening..."
|
return seconds > 0 ? `Listening (${seconds}s)` : "Listening..."
|
||||||
},
|
},
|
||||||
|
deleteMapping(m) { this.request("delete", { id: m.id }) },
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
<div style="padding: var(--sp-3);">
|
<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>
|
<GxNotice v-if="!offroad" text="Mappings can only be changed while offroad. Mapped buttons continue working onroad." style="margin:0 0 var(--sp-2);" />
|
||||||
<p v-if="error" style="color: var(--error);">{{ error }}</p>
|
<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>
|
<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;">
|
<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" :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>
|
||||||
<div v-if="testing && lastTested" style="margin-bottom:12px;">
|
<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>
|
<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>
|
<span class="gx-row__desc">{{ configured(slot) ? (slot.label || slot.key) : 'Not configured' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="configured(slot)" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled() || testing" @click="learn(i)">{{ listenLabel(i) }}</button>
|
<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>
|
||||||
</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>
|
<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>
|
<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>
|
<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 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" style="border:none; padding:0; flex-wrap:wrap;">
|
||||||
<div class="gx-row__info">
|
<div class="gx-row__info">
|
||||||
@@ -154,8 +157,8 @@ export const WheelControls = {
|
|||||||
:value="Number(slot.value ?? 30)" :disabled="disabled()" @change="onSpeedChange(i, $event)" />
|
:value="Number(slot.value ?? 30)" :disabled="disabled()" @change="onSpeedChange(i, $event)" />
|
||||||
</div>
|
</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="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">
|
<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="margin-right:4px;">{{ m.event_name || ('Button ' + m.event_code) }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,23 +56,24 @@ export function matchesSettingValueCondition(param, values) {
|
|||||||
return allowedValues.some((value) => toSelectValue(value) === currentValue)
|
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) {
|
export function isSettingVisible(section, param, values) {
|
||||||
if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
|
if (isHiddenByConditions(section, param, values)) return false
|
||||||
if (param.requires_capability && !values[param.requires_capability]) return false
|
|
||||||
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
|
|
||||||
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
|
|
||||||
if (values[GALAXY_DEVELOPER_MODE_KEY]) return true
|
if (values[GALAXY_DEVELOPER_MODE_KEY]) return true
|
||||||
return section.name === "Favorites" || param.settings_tier === "simple"
|
return section.name === "Favorites" || param.settings_tier === "simple"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAdvancedHiddenByDeveloperMode(section, param, values) {
|
export function isAdvancedHiddenByDeveloperMode(section, param, values) {
|
||||||
if (param.settings_tier !== "advanced") return false
|
if (param.settings_tier !== "advanced") return false
|
||||||
if (HIDDEN_SETTING_KEYS.has(param.key)) return false
|
return !isHiddenByConditions(section, param, values)
|
||||||
if (!isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
|
|
||||||
if (param.requires_capability && !values[param.requires_capability]) return false
|
|
||||||
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
|
|
||||||
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countAdvancedHiddenByDeveloperMode(layout, values) {
|
export function countAdvancedHiddenByDeveloperMode(layout, values) {
|
||||||
|
|||||||
@@ -72,12 +72,7 @@ function applyRoute(route, { scrollToTop = false } = {}) {
|
|||||||
|
|
||||||
export function navigate(target) {
|
export function navigate(target) {
|
||||||
const { path } = parseHash(target)
|
const { path } = parseHash(target)
|
||||||
if (path === currentPath()) {
|
if (path !== currentPath()) pushIfNew(path)
|
||||||
applyRoute(target)
|
|
||||||
window.location.hash = toHash(target)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pushIfNew(path)
|
|
||||||
applyRoute(target)
|
applyRoute(target)
|
||||||
window.location.hash = toHash(target)
|
window.location.hash = toHash(target)
|
||||||
}
|
}
|
||||||
@@ -96,7 +91,7 @@ export function goBack() {
|
|||||||
window.location.hash = prev
|
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) {
|
export function toolHref(link) {
|
||||||
const path = link.split("?")[0]
|
const path = link.split("?")[0]
|
||||||
@@ -107,13 +102,8 @@ export function toolHref(link) {
|
|||||||
export function initRouter() {
|
export function initRouter() {
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
const route = (window.location.hash || "").replace(/^#/, "") || "/"
|
const route = (window.location.hash || "").replace(/^#/, "") || "/"
|
||||||
const { path, params } = parseHash(route)
|
applyRoute(route)
|
||||||
const pathChanged = path !== store.route
|
|
||||||
store.route = path
|
|
||||||
store.params = params
|
|
||||||
store.drawerOpen = false
|
|
||||||
pushIfNew(route)
|
pushIfNew(route)
|
||||||
if (pathChanged) window.scrollTo(0, 0)
|
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", apply)
|
window.addEventListener("hashchange", apply)
|
||||||
store.history = ["/"]
|
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 = {
|
export const Home = {
|
||||||
name: "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: `
|
template: `
|
||||||
<div class="gx-view">
|
<div class="dh-view">
|
||||||
<GalaxyEmbed src="/classic" title="Home" />
|
<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>
|
</div>
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { api, showSnackbar } from "../api.js"
|
import { api, showSnackbar } from "../api.js"
|
||||||
import { useLogStream } from "../composables.js"
|
import { useLogStream, useTabRouting } from "../composables.js"
|
||||||
import { GalaxyConfirm } from "../components/GalaxyModal.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) {
|
function parseLogDate(filename) {
|
||||||
const m = filename.match(/(\d{4})-(\d{2})-(\d{2})[T_]?(\d{2})-?(\d{2})-?(\d{2})?/)
|
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 = {
|
export const Logs = {
|
||||||
name: "Logs",
|
name: "Logs",
|
||||||
components: { GalaxyEmbed },
|
components: { TroubleshootPanel, GalaxyTabs },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tab: "errors",
|
TABS,
|
||||||
errorFiles: [],
|
errorFiles: [],
|
||||||
errorsLoading: true,
|
errorsLoading: true,
|
||||||
selectedLog: "",
|
selectedLog: "",
|
||||||
@@ -23,12 +30,13 @@ export const Logs = {
|
|||||||
logLoading: false,
|
logLoading: false,
|
||||||
tmuxFiles: [],
|
tmuxFiles: [],
|
||||||
tmuxLoading: true,
|
tmuxLoading: true,
|
||||||
troubleshootData: null,
|
|
||||||
troubleshootRunning: false,
|
|
||||||
searchFilter: "",
|
searchFilter: "",
|
||||||
tmuxAutoScroll: true,
|
tmuxAutoScroll: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
return useTabRouting("/logs", { errors: "errors", tmux: "tmux", troubleshoot: "troubleshoot" })
|
||||||
|
},
|
||||||
created() {
|
created() {
|
||||||
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
|
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
|
||||||
},
|
},
|
||||||
@@ -45,7 +53,6 @@ export const Logs = {
|
|||||||
this.loadErrorLogs()
|
this.loadErrorLogs()
|
||||||
this.loadTmuxLogs()
|
this.loadTmuxLogs()
|
||||||
this.stream.start()
|
this.stream.start()
|
||||||
this.loadTroubleshoot()
|
|
||||||
},
|
},
|
||||||
beforeUnmount() { this.stream.destroy() },
|
beforeUnmount() { this.stream.destroy() },
|
||||||
computed: {
|
computed: {
|
||||||
@@ -140,26 +147,6 @@ export const Logs = {
|
|||||||
showSnackbar(ok ? "All logs deleted!" : "Delete failed.", ok ? "info" : "error")
|
showSnackbar(ok ? "All logs deleted!" : "Delete failed.", ok ? "info" : "error")
|
||||||
this.tmuxFiles = []
|
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) {
|
onTmuxScroll(e) {
|
||||||
const el = e.target
|
const el = e.target
|
||||||
this.tmuxAutoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
this.tmuxAutoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||||
@@ -169,16 +156,12 @@ export const Logs = {
|
|||||||
<div class="gx-view">
|
<div class="gx-view">
|
||||||
<h2 style="margin-top:0;">Logs & Diagnostics</h2>
|
<h2 style="margin-top:0;">Logs & Diagnostics</h2>
|
||||||
|
|
||||||
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
|
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
|
||||||
<button type="button" class="gx-chip" :style="tab==='errors'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='errors'">Error Logs</button>
|
|
||||||
<button type="button" class="gx-chip" :style="tab==='tmux'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='tmux'">Tmux Live Log</button>
|
|
||||||
<button type="button" class="gx-chip" :style="tab==='troubleshoot'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='troubleshoot'">Troubleshoot</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="tab === 'errors'">
|
<template v-if="tab === 'errors'">
|
||||||
<div style="display:flex; gap:8px; margin-bottom:8px; align-items:center;">
|
<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..." />
|
<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>
|
</div>
|
||||||
<section class="gx-card">
|
<section class="gx-card">
|
||||||
<div v-if="errorsLoading" class="gx-loading">Loading...</div>
|
<div v-if="errorsLoading" class="gx-loading">Loading...</div>
|
||||||
@@ -190,7 +173,7 @@ export const Logs = {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:6px;">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -232,14 +215,14 @@ export const Logs = {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:6px;">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<GalaxyEmbed src="/troubleshoot" title="Troubleshoot" />
|
<TroubleshootPanel />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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 { api, showSnackbar } from "../api.js"
|
||||||
import { GalaxySection } from "../components/GalaxySection.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"
|
import { useTabRouting } from "../composables.js"
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
nav: "Destination",
|
||||||
|
maps: "Maps",
|
||||||
|
keys: "App Keys",
|
||||||
|
speeds: "Speed Limits",
|
||||||
|
}
|
||||||
|
|
||||||
export const Navigation = {
|
export const Navigation = {
|
||||||
name: "Navigation",
|
name: "Navigation",
|
||||||
components: { GalaxySection, GalaxyEmbed },
|
components: { GalaxySection, MapsPanel, NavigationKeysPanel, SpeedLimitsPanel, GalaxyTabs },
|
||||||
data() {
|
data() {
|
||||||
return { destination: "", favorites: [], navLoading: true }
|
return { TABS, destination: "", favorites: [], navLoading: true }
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
return useTabRouting("/navigation", {
|
return useTabRouting("/navigation", {
|
||||||
nav: "", maps: "maps", keys: "keys", speeds: "speeds",
|
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() },
|
mounted() { this.loadNavigation() },
|
||||||
methods: {
|
methods: {
|
||||||
async loadNavigation() {
|
async loadNavigation() {
|
||||||
@@ -60,12 +52,7 @@ export const Navigation = {
|
|||||||
<div class="gx-view">
|
<div class="gx-view">
|
||||||
<h2 style="margin-top:0;">Navigation & Maps</h2>
|
<h2 style="margin-top:0;">Navigation & Maps</h2>
|
||||||
|
|
||||||
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
|
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
|
||||||
<button type="button" class="gx-chip" :style="tab==='nav'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('nav')">Destination</button>
|
|
||||||
<button type="button" class="gx-chip" :style="tab==='maps'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('maps')">Maps</button>
|
|
||||||
<button type="button" class="gx-chip" :style="tab==='keys'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('keys')">App Keys</button>
|
|
||||||
<button type="button" class="gx-chip" :style="tab==='speeds'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('speeds')">Speed Limits</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="tab === 'nav'">
|
<template v-if="tab === 'nav'">
|
||||||
<GalaxySection title="Navigation Destination" icon="bi-geo-alt-fill">
|
<GalaxySection title="Navigation Destination" icon="bi-geo-alt-fill">
|
||||||
@@ -83,7 +70,17 @@ export const Navigation = {
|
|||||||
</GalaxySection>
|
</GalaxySection>
|
||||||
</template>
|
</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>
|
</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 & 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 { api, showSnackbar } from "../api.js"
|
||||||
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
||||||
import { GalaxySection } from "../components/GalaxySection.js"
|
import { GalaxySection } from "../components/GalaxySection.js"
|
||||||
|
import { GalaxyTabs } from "../components/GalaxyTabs.js"
|
||||||
|
|
||||||
function fmtDuration(seconds) {
|
function fmtDuration(seconds) {
|
||||||
seconds = Number(seconds) || 0
|
seconds = Number(seconds) || 0
|
||||||
@@ -32,7 +33,7 @@ function normalizeRoute(r) {
|
|||||||
|
|
||||||
export const Recordings = {
|
export const Recordings = {
|
||||||
name: "Recordings",
|
name: "Recordings",
|
||||||
components: { GalaxySection },
|
components: { GalaxySection, GalaxyTabs },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -116,6 +117,9 @@ export const Recordings = {
|
|||||||
showSnackbar("Delete failed.", "error")
|
showSnackbar("Delete failed.", "error")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setPreservedFilter(key) {
|
||||||
|
this.showPreservedOnly = key === "preserved"
|
||||||
|
},
|
||||||
async togglePreserved(route) {
|
async togglePreserved(route) {
|
||||||
try {
|
try {
|
||||||
await api.setRoutePreserved(route.name, !route.is_preserved)
|
await api.setRoutePreserved(route.name, !route.is_preserved)
|
||||||
@@ -224,8 +228,9 @@ export const Recordings = {
|
|||||||
<option value="longest">Longest duration</option>
|
<option value="longest">Longest duration</option>
|
||||||
<option value="shortest">Shortest duration</option>
|
<option value="shortest">Shortest duration</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="gx-chip" :style="!showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=false">All</button>
|
</div>
|
||||||
<button type="button" class="gx-chip" :style="showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=true">Preserved</button>
|
<div style="padding: 0 var(--sp-3) var(--sp-3);">
|
||||||
|
<GalaxyTabs :items="{ all: 'All', preserved: 'Preserved' }" :active="showPreservedOnly ? 'preserved' : 'all'" @select="setPreservedFilter" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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="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="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 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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -254,7 +259,7 @@ export const Recordings = {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
|
<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 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -273,33 +278,35 @@ export const Recordings = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="gx-fade">
|
<Teleport to="body">
|
||||||
<div v-if="playerRoute" class="gx-scrim" @click.self="closePlayer">
|
<transition name="gx-fade">
|
||||||
<div class="gx-sheet" style="max-width:640px; width:100%;" role="dialog" aria-label="Route video player">
|
<div v-if="playerRoute" class="gx-scrim gx-scrim--bottomsheet" @click.self="closePlayer">
|
||||||
<div class="gx-section__header" style="cursor:default;">
|
<div class="gx-sheet" role="dialog" aria-label="Route video player">
|
||||||
<i class="bi bi-camera-video"></i>
|
<div class="gx-section__header" style="cursor:default;">
|
||||||
<span class="gx-section__title">{{ playerRoute.displayName }}</span>
|
<i class="bi bi-camera-video"></i>
|
||||||
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closePlayer"><i class="bi bi-x-lg"></i></button>
|
<span class="gx-section__title">{{ playerRoute.displayName }}</span>
|
||||||
</div>
|
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closePlayer"><i class="bi bi-x-lg"></i></button>
|
||||||
<div style="padding: var(--sp-3);">
|
</div>
|
||||||
<div v-if="playerError" class="gx-empty" style="color: var(--error);">{{ playerError }}</div>
|
<div style="padding: var(--sp-3);">
|
||||||
<div v-else-if="playerLoading" class="gx-loading"><i class="bi bi-hourglass-split"></i> Loading video...</div>
|
<div v-if="playerError" class="gx-empty" style="color: var(--error);">{{ playerError }}</div>
|
||||||
<template v-else-if="segments.length">
|
<div v-else-if="playerLoading" class="gx-loading"><i class="bi bi-hourglass-split"></i> Loading video...</div>
|
||||||
<video ref="player" class="gx-video" controls muted playsinline preload="metadata"></video>
|
<template v-else-if="segments.length">
|
||||||
<div style="display:flex; gap:8px; padding: var(--sp-3) 0 0; flex-wrap:wrap; align-items:center;">
|
<video ref="player" class="gx-video" controls muted playsinline preload="metadata"></video>
|
||||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-skip-start-fill"></i></button>
|
<div style="display:flex; gap:8px; padding: var(--sp-3) 0 0; flex-wrap:wrap; align-items:center;">
|
||||||
<select class="gx-field" :value="current" @change="current = Number($event.target.value); playSegment()">
|
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-skip-start-fill"></i></button>
|
||||||
<option v-for="(s,i) in segments" :key="i" :value="i">Segment {{ i + 1 }}</option>
|
<select class="gx-field" :value="current" @change="current = Number($event.target.value); playSegment()">
|
||||||
</select>
|
<option v-for="(s,i) in segments" :key="i" :value="i">Segment {{ i + 1 }}</option>
|
||||||
<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>
|
</select>
|
||||||
<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 gx-btn--tonal" :disabled="current>=segments.length-1" @click="current++; playSegment()"><i class="bi bi-skip-end-fill"></i></button>
|
||||||
<button type="button" class="gx-btn" @click="downloadRoute"><i class="bi bi-download"></i> Download</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>
|
||||||
</div>
|
<button type="button" class="gx-btn" @click="downloadRoute"><i class="bi bi-download"></i> Download</button>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</transition>
|
||||||
</transition>
|
</Teleport>
|
||||||
</div>
|
</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 { usePolling } from "../composables.js"
|
||||||
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
||||||
import { GalaxySection } from "../components/GalaxySection.js"
|
import { GalaxySection } from "../components/GalaxySection.js"
|
||||||
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
|
import { GxNotice } from "../components/GxNotice.js"
|
||||||
|
|
||||||
function shortCommit(commit) {
|
function shortCommit(commit) {
|
||||||
return String(commit || "").slice(0, 10) || "—"
|
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 = {
|
export const SystemTools = {
|
||||||
name: "SystemTools",
|
name: "SystemTools",
|
||||||
components: { GalaxySection, GalaxyEmbed },
|
components: { GalaxySection, GxNotice },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
branches: [],
|
branches: [],
|
||||||
@@ -26,6 +32,21 @@ export const SystemTools = {
|
|||||||
beforeUnmount() { this.poll?.destroy() },
|
beforeUnmount() { this.poll?.destroy() },
|
||||||
computed: {
|
computed: {
|
||||||
updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
|
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: {
|
methods: {
|
||||||
shortCommit,
|
shortCommit,
|
||||||
@@ -155,16 +176,7 @@ export const SystemTools = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async factoryReset() {
|
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
|
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("Factory resetting...")
|
|
||||||
} catch (e) {
|
|
||||||
showSnackbar(e?.message || "Factory reset failed.", "error")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async saveMe() {
|
|
||||||
if (!(await GalaxyConfirm({ title: "SAVE ME", message: "This will factory reset the device by wiping params, backups, themes, models, maps, and route data. The device will reboot when the wipe is complete. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
|
|
||||||
try {
|
try {
|
||||||
await api.factoryReset()
|
await api.factoryReset()
|
||||||
showSnackbar("SAVE ME initiated — factory resetting...")
|
showSnackbar("SAVE ME initiated — factory resetting...")
|
||||||
@@ -186,23 +198,11 @@ export const SystemTools = {
|
|||||||
<div>
|
<div>
|
||||||
<h2 style="margin-top:0;">System Tools</h2>
|
<h2 style="margin-top:0;">System Tools</h2>
|
||||||
|
|
||||||
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat">
|
<GalaxySection title="Software & Updates" icon="bi-arrow-up-circle" :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" style="background:var(--error);color:var(--on-error);" @click="saveMe">SAVE ME</button>
|
|
||||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllDrivingRoutes">Delete All Driving Routes</button>
|
|
||||||
<input ref="restoreInput" type="file" accept=".json" style="display:none;" @change="onRestoreFile" />
|
|
||||||
</div>
|
|
||||||
<GalaxyEmbed src="/manage_toggles" title="Backup & Restore" style="min-height:60vh; margin: var(--sp-3);" />
|
|
||||||
</GalaxySection>
|
|
||||||
|
|
||||||
<GalaxySection title="Software & Updates" icon="bi-arrow-up-circle">
|
|
||||||
<div style="padding: var(--sp-3);">
|
<div style="padding: var(--sp-3);">
|
||||||
<div v-if="branchLoading" class="gx-loading">Loading update info...</div>
|
<div v-if="branchLoading" class="gx-loading">Loading update info...</div>
|
||||||
<template v-else>
|
<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 v-if="fastStatus" class="gx-card" style="margin-bottom:12px;">
|
||||||
<div class="gx-section__header">
|
<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 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">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 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.message" class="gx-note">{{ 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.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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 style="margin:12px 0 8px;">Switch branch</h4>
|
<div class="gx-card" style="margin-bottom:12px;">
|
||||||
<div class="gx-row" style="border-top:none; padding:4px 0;">
|
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Switch Branch</span></div>
|
||||||
<select class="gx-field gx-field--full" :disabled="!!isOnroad" @change="onBranchSelect">
|
<div style="padding: var(--sp-3);">
|
||||||
<option v-if="!branches.length" value="">No branches available</option>
|
<select class="gx-field gx-field--full" :disabled="!!isOnroad" @change="onBranchSelect">
|
||||||
<option v-for="b in branches" :key="b" :value="b" :selected="b === currentBranch">{{ b === currentBranch ? b + ' (current)' : b }}</option>
|
<option v-if="!branches.length" value="">No branches available</option>
|
||||||
</select>
|
<option v-for="b in branches" :key="b" :value="b" :selected="b === currentBranch">{{ b === currentBranch ? b + ' (current)' : b }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
|
<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">
|
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || !!fastStatus?.running" @click="checkUpdates">
|
||||||
<i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i>
|
<i v-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('recover')">Recover</button>
|
||||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('rollback')">Rollback</button>
|
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('rollback')">Rollback</button>
|
||||||
</div>
|
</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.
|
The device is up to date. Update becomes available only after a check finds a newer commit.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</GalaxySection>
|
</GalaxySection>
|
||||||
|
|
||||||
<GalaxySection title="Danger Zone" icon="bi-exclamation-triangle">
|
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat" :collapsible="false">
|
||||||
<div style="padding: var(--sp-3);">
|
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
<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" @click="backupToggles"><i class="bi bi-download"></i> Backup Toggles</button>
|
||||||
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="factoryReset">Factory Reset Device</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>
|
</div>
|
||||||
</GalaxySection>
|
</GalaxySection>
|
||||||
</div>
|
</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",
|
"/bluetooth": "Bluetooth",
|
||||||
"/wheel-controls": "Controllers",
|
"/wheel-controls": "Controllers",
|
||||||
"/vehicle_features": "Vehicle Features",
|
"/vehicle_features": "Vehicle Features",
|
||||||
"/manage_v_asm": "V-Adj Spot Monitor",
|
|
||||||
"/manage_pip_sidecam": "PiP Side Camera",
|
|
||||||
"/manage_doors": "Lock/Unlock Doors",
|
"/manage_doors": "Lock/Unlock Doors",
|
||||||
"/manage_tsk": "Toyota Security Keys",
|
"/manage_tsk": "Toyota Security Keys",
|
||||||
"/set_navigation_destination": "Navigation Destination",
|
"/set_navigation_destination": "Navigation Destination",
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { navigate, toolHref } from "../store.js"
|
import { navigate, toolHref } from "../store.js"
|
||||||
|
|
||||||
const TOOLS = [
|
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: "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: "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: "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: "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: "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: "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: "Tuning, Plots & Testing", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed tuning, live plots, testing grounds" },
|
||||||
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box", desc: "Adjust spot-monitor window" },
|
|
||||||
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" },
|
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" },
|
||||||
].sort((a, b) => a.name.localeCompare(b.name))
|
].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 = {
|
export const Tuning = {
|
||||||
name: "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: `
|
template: `
|
||||||
<div class="gx-view">
|
<div class="gx-view">
|
||||||
<div class="gx-section__header" style="padding: var(--sp-3) var(--sp-4);">
|
<h2 style="margin-top:0;">Tuning, Plots & Testing</h2>
|
||||||
<i class="bi bi-sign-turn-right"></i>
|
|
||||||
<span class="gx-section__title">Tuning & Maneuvers</span>
|
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
|
||||||
</div>
|
|
||||||
<GalaxyEmbed src="/tuning" title="Tuning" />
|
<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>
|
</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 { WheelControls } from "../components/WheelControls.js"
|
||||||
import { BluetoothPanel } from "../components/BluetoothPanel.js"
|
import { BluetoothPanel } from "../components/BluetoothPanel.js"
|
||||||
import { GalaxySection } from "../components/GalaxySection.js"
|
import { GalaxySection } from "../components/GalaxySection.js"
|
||||||
|
import { GalaxyTabs } from "../components/GalaxyTabs.js"
|
||||||
|
import { useTabRouting } from "../composables.js"
|
||||||
|
|
||||||
const FEATURES = [
|
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: "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" },
|
{ 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 = {
|
export const Vehicle = {
|
||||||
name: "Vehicle",
|
name: "Vehicle",
|
||||||
components: { WheelControls, BluetoothPanel, GalaxySection },
|
components: { WheelControls, BluetoothPanel, GalaxySection, GalaxyTabs },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
TABS,
|
||||||
features: FEATURES,
|
features: FEATURES,
|
||||||
featureStatus: {},
|
featureStatus: {},
|
||||||
busy: "",
|
busy: "",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
return useTabRouting("/vehicle", { controllers: "controllers", bluetooth: "bluetooth", features: "features" })
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
featureList() { return this.features },
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
statusOf(key) { return this.featureStatus[key] || "untested" },
|
statusOf(key) { return this.featureStatus[key] || "untested" },
|
||||||
async openFeature(f) {
|
async openFeature(f) {
|
||||||
@@ -47,33 +62,37 @@ export const Vehicle = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div>
|
<div class="gx-view">
|
||||||
<h2 style="margin-top:0;">Vehicle Controls</h2>
|
<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 />
|
<WheelControls />
|
||||||
</GalaxySection>
|
</template>
|
||||||
|
|
||||||
<GalaxySection title="Bluetooth" icon="bi-bluetooth">
|
<template v-else-if="tab === 'bluetooth'">
|
||||||
<BluetoothPanel />
|
<BluetoothPanel />
|
||||||
</GalaxySection>
|
</template>
|
||||||
|
|
||||||
<GalaxySection title="Vehicle Features" icon="bi-check2-square">
|
<template v-else>
|
||||||
<div style="padding: var(--sp-3); display:grid; gap:8px;">
|
<GalaxySection title="Vehicle Features" icon="bi-check2-square">
|
||||||
<button v-for="f in features" :key="f.key" type="button"
|
<div style="padding: var(--sp-3); display:grid; gap:8px;">
|
||||||
class="gx-row" style="width:100%; border:none; background:transparent; color:inherit; cursor:pointer; text-align:left;"
|
<button v-for="f in featureList" :key="f.key" type="button"
|
||||||
@click="openFeature(f)">
|
class="gx-row" style="width:100%; border:none; background:transparent; color:inherit; cursor:pointer; text-align:left;"
|
||||||
<div class="gx-row__info">
|
@click="openFeature(f)">
|
||||||
<span class="gx-row__label"><i class="bi" :class="f.icon" style="margin-right:6px; color:var(--primary);"></i>{{ f.name }}</span>
|
<div class="gx-row__info">
|
||||||
<span class="gx-row__desc">{{ f.desc }}</span>
|
<span class="gx-row__label"><i class="bi" :class="f.icon" style="margin-right:6px; color:var(--primary);"></i>{{ f.name }}</span>
|
||||||
</div>
|
<span class="gx-row__desc">{{ f.desc }}</span>
|
||||||
<span v-if="busy === f.key" class="gx-chip" style="background:var(--surface-variant);">Checking...</span>
|
</div>
|
||||||
<span v-else-if="statusOf(f.key) === 'denied'" class="gx-chip" style="background:var(--error);">Not supported</span>
|
<span v-if="busy === f.key" class="gx-chip" style="background:var(--surface-variant);">Checking...</span>
|
||||||
<i v-else class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
|
<span v-else-if="statusOf(f.key) === 'denied'" class="gx-chip" style="background:var(--error);">Not supported</span>
|
||||||
</button>
|
<i v-else class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
|
||||||
<p style="color:var(--text-muted); margin:0;">These features verify vehicle compatibility when launched.</p>
|
</button>
|
||||||
</div>
|
<p style="color:var(--text-muted); margin:0;">These features verify vehicle compatibility when launched.</p>
|
||||||
</GalaxySection>
|
</div>
|
||||||
|
</GalaxySection>
|
||||||
|
</template>
|
||||||
</div>
|
</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.
|
# Endpoint usage may live in a view or in a reusable component it composes.
|
||||||
checks = {
|
checks = {
|
||||||
"js/views/Recordings.js": ["/api/routes", "getRoutesStream", "getRouteLogs"],
|
"js/views/Recordings.js": ["/api/routes", "getRoutesStream", "getRouteLogs"],
|
||||||
"js/views/Logs.js": ["getErrorLogs", "tmuxSnapshot", "runTroubleshoot"],
|
"js/views/Logs.js": ["getErrorLogs", "tmuxSnapshot"],
|
||||||
"js/views/Tuning.js": ["GalaxyEmbed", 'src="/tuning"'],
|
"js/components/TroubleshootPanel.js": ["getTroubleshoot", "resetTroubleshootSection", "GalaxyConfirm"],
|
||||||
"js/views/Navigation.js": ["getNavigation", "setNavigation"],
|
"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/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"],
|
||||||
"js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"],
|
"js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"],
|
||||||
"js/components/WheelControls.js": ["getWheelControlsStatus"],
|
"js/components/WheelControls.js": ["getWheelControlsStatus"],
|
||||||
@@ -126,7 +127,6 @@ def test_ui_routes_ported_views_natively_no_classic_fallback():
|
|||||||
app = _read("js/app.js")
|
app = _read("js/app.js")
|
||||||
shell = _read("js/components/AppShell.js")
|
shell = _read("js/components/AppShell.js")
|
||||||
tools = _read("js/views/Tools.js")
|
tools = _read("js/views/Tools.js")
|
||||||
home = _read("js/views/Home.js")
|
|
||||||
|
|
||||||
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
|
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
|
||||||
assert view in app, f"app.js should register {view}"
|
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).
|
# and is intentionally absent from the Tools page).
|
||||||
for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/system"]:
|
for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/system"]:
|
||||||
assert tool in tools, f"Tools grid should route {tool} natively"
|
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
|
assert "/cameras" in tools, "Tools grid should route the camera hub natively"
|
||||||
# instead of the removed native Annotation Tool.
|
assert "/manage_v_asm" not in tools and "/manage_pip_sidecam" not in tools
|
||||||
assert "/manage_v_asm" in tools and "/manage_pip_sidecam" in tools
|
|
||||||
# Unmigrated tools are embedded (ToolEmbed), never a full-page redirect.
|
# Unmigrated tools are embedded (ToolEmbed), never a full-page redirect.
|
||||||
assert "return ToolEmbed" in app
|
assert "return ToolEmbed" in app
|
||||||
assert "ToolEmbed" in app
|
assert "ToolEmbed" in app
|
||||||
# Home renders the classic dashboard as a shared page-in-page embed.
|
home = _read("js/views/Home.js")
|
||||||
assert 'src="/classic"' in home and "GalaxyEmbed" in home
|
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.
|
# 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")]:
|
for src in [shell, tools, home, _read("js/views/ToolEmbed.js"), _read("js/store.js")]:
|
||||||
assert "window.location.href" not in src
|
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():
|
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")
|
tuning = _read("js/views/Tuning.js")
|
||||||
embed_comp = _read("js/components/GalaxyEmbed.js")
|
assert "GalaxyEmbed" not in tuning and 'src="/tuning"' not in tuning, "Tuning must be native, not a classic embed"
|
||||||
assert 'src="/tuning"' in tuning and "GalaxyEmbed" in tuning, "Tuning should embed the classic tuning SPA"
|
assert "LateralTuningPanel" in tuning and "LongitudinalManeuvers" in tuning
|
||||||
assert "embedded=1" in embed_comp
|
|
||||||
vehicle = _read("js/views/Vehicle.js")
|
vehicle = _read("js/views/Vehicle.js")
|
||||||
assert "ParamSections" not in vehicle, "Vehicle must not render redundant toggles"
|
assert "ParamSections" not in vehicle, "Vehicle must not render redundant toggles"
|
||||||
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle
|
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 "goBack" in shell or "back()" in shell
|
||||||
assert "gx-drawer" in shell
|
assert "gx-drawer" in shell
|
||||||
assert "gx-appbar" 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():
|
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/"
|
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():
|
def _node_exe():
|
||||||
candidates = [
|
candidates = [
|
||||||
shutil.which("node"),
|
shutil.which("node"),
|
||||||
|
|||||||
Reference in New Issue
Block a user