big dipper

This commit is contained in:
Prabhaav Pillai
2026-09-06 01:34:11 -04:00
parent 7d46313213
commit 9cb1b6b11d
19 changed files with 582 additions and 53 deletions
+1
View File
@@ -310,6 +310,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DeveloperSidebarMetric7", {PERSISTENT, INT, "7", "0", 3}},
{"DeveloperUI", {PERSISTENT, BOOL, "0", "0", 3}},
{"GalaxyDeveloperMode", {PERSISTENT | DONT_LOG, BOOL, "0", "0", 0, SETTINGS_SIMPLE}},
{"GalaxyMobileDefault", {PERSISTENT | DONT_LOG, BOOL, "0", "0", 0, SETTINGS_ADVANCED}},
{"DeveloperWidgets", {PERSISTENT, BOOL, "1", "0", 3}},
{"DeviceManagement", {PERSISTENT, BOOL, "1", "0", 1, SETTINGS_SIMPLE}},
{"DeviceShutdown", {PERSISTENT, INT, "6", "6", 1, SETTINGS_SIMPLE}},
@@ -4914,6 +4914,15 @@
"is_parent_toggle": true,
"settings_tier": "simple"
},
{
"key": "GalaxyMobileDefault",
"label": "Try the Big Dipper Web UI",
"description": "Open the Big Dipper at the top-level Galaxy link instead of the classic Galaxy. The classic UI remains available at /classic and Big Dipper at /mobile regardless of this toggle.",
"picker_description": "Serve the Big Dipper as the default landing page.",
"data_type": "bool",
"ui_type": "toggle",
"settings_tier": "advanced"
},
{
"key": "AlphaLongitudinalEnabled",
"label": "openpilot Longitudinal Control (Alpha)",
@@ -342,6 +342,10 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-appbar__back:active { transform: scale(0.92); }
.gx-appbar__title {
background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: var(--fs-lg);
font-weight: var(--fw-bold);
white-space: nowrap;
@@ -7,7 +7,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Galaxy">
<meta name="apple-mobile-web-app-title" content="Big Dipper">
<meta name="format-detection" content="telephone=no">
<meta name="theme-color" content="#8b6cc5" />
<link rel="manifest" href="/assets/mobile/manifest.json" crossorigin="use-credentials">
@@ -26,7 +26,7 @@
}
</script>
<title>Galaxy</title>
<title>Big Dipper</title>
</head>
<body>
@@ -105,11 +105,38 @@ export const api = {
deleteAllRoutes(includePreserved) { return request(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" }) },
getRouteLogs(name) { return request(`/api/routes/${encodeURIComponent(name)}/logs`) },
getScreenRecordings() { return request("/api/screen_recordings/list") },
async screenRecordingsStream({ onProgress, onRecordings, signal } = {}) {
const res = await fetch("/api/screen_recordings/list", { signal })
if (!res.ok || !res.body) throw new Error(`Screen recordings request failed (${res.status})`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split(/\r?\n\r?\n/)
buffer = events.pop() || ""
for (const event of events) {
const lines = event.split(/\r?\n/).filter((l) => l.startsWith("data:"))
if (!lines.length) continue
try {
const payload = JSON.parse(lines.map((l) => l.slice(5).trimStart()).join("\n"))
if (Number.isFinite(payload.progress)) onProgress?.(payload.progress)
onRecordings?.(Array.isArray(payload.recordings) ? payload.recordings : [])
} catch (e) { }
}
}
},
screenRecordingVideoUrl(filename) { return `/api/screen_recordings/download/${encodeURIComponent(filename)}` },
deleteScreenRecording(filename) { return request(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" }) },
deleteAllScreenRecordings() { return request("/api/screen_recordings/delete_all", { method: "DELETE" }) },
renameScreenRecording(oldName, newName) { return request("/api/screen_recordings/rename", { method: "POST", data: { old: oldName, new: newName } }) },
getModelLab() { return request("/api/model-laboratory", { cache: "no-store" }) },
saveModelLab(config) { return request("/api/model-laboratory", { method: "PUT", data: config }) },
prepareModelLabArtifact(model) { return request("/api/model-laboratory/download", { method: "POST", data: { model } }) },
getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) },
getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) },
deleteErrorLog(filename) { return delOk(`/api/error_logs/${encodeURIComponent(filename)}`) },
@@ -18,6 +18,7 @@ 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 { ModelLaboratory } from "./views/ModelLaboratory.js"
import { Cameras } from "./views/Cameras.js"
import { store, initRouter, navigate } from "./store.js"
import { showSnackbar } from "./api.js"
@@ -52,6 +53,7 @@ const VIEWS = {
"/plots": Plots,
"/testing_ground": TestingGround,
"/theme_maker": ThemeMaker,
"/model_laboratory": ModelLaboratory,
"/cameras": Cameras,
}
@@ -102,7 +102,7 @@ export const AppShell = {
<button type="button" class="gx-icon-btn gx-menu-btn" aria-label="Menu" @click="store.drawerOpen = true">
<i class="bi bi-list"></i>
</button>
<span class="gx-appbar__title">Galaxy</span>
<span class="gx-appbar__title">Big Dipper</span>
<div class="gx-searchwrap">
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search toggles..."
v-model="search" aria-label="Search toggles" />
@@ -128,8 +128,8 @@ export const AppShell = {
</transition>
<aside class="gx-drawer" :class="{ open: store.drawerOpen }">
<div class="gx-drawer__header">
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<span class="gx-drawer-title">Galaxy</span>
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Big Dipper logo" />
<span class="gx-drawer-title">Big Dipper</span>
</div>
<div class="gx-nav-section">
<div class="gx-nav-section__title">Main</div>
@@ -1,3 +1,5 @@
import { pwaState, requestInstall } from "../install.js"
export const FIRESTAR_HOST = "galaxy.firestar.link"
export function isFirestarOrigin() {
@@ -9,10 +11,6 @@ function isIos() {
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: {
@@ -21,10 +19,8 @@ export const PwaInstallSection = {
},
data() {
return {
installed: isStandalone(),
isIos: isIos(),
onFirestar: isFirestarOrigin(),
deferredPrompt: null,
}
},
computed: {
@@ -35,33 +31,19 @@ export const PwaInstallSection = {
if (this.onFirestar) return window.location.origin
return ""
},
canInstall() { return !!this.deferredPrompt && !this.installed },
showManual() { return this.isIos && !this.installed && !this.deferredPrompt },
installed() { return pwaState.installed },
canInstall() { return !!pwaState.deferredPrompt && !pwaState.installed },
showManual() { return this.isIos && !pwaState.installed && !pwaState.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
await requestInstall()
},
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">
@@ -128,4 +110,4 @@ export const PwaInstallSection = {
</div>
</section>
`,
}
}
@@ -0,0 +1,30 @@
import { reactive } from "vue"
function inStandaloneMode() {
return window.matchMedia("(display-mode: standalone)").matches || !!window.navigator.standalone
}
export const pwaState = reactive({
deferredPrompt: null,
installed: inStandaloneMode(),
})
function capture(event) {
event.preventDefault()
pwaState.deferredPrompt = event
}
window.addEventListener("beforeinstallprompt", capture)
window.addEventListener("appinstalled", () => {
pwaState.deferredPrompt = null
pwaState.installed = true
})
export async function requestInstall() {
const prompt = pwaState.deferredPrompt
if (!prompt) return false
prompt.prompt()
try { await prompt.userChoice } catch (e) { /* user dismissed */ }
pwaState.deferredPrompt = null
return true
}
@@ -91,7 +91,7 @@ export function goBack() {
window.location.hash = prev
}
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"])
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", "/model_laboratory", "/cameras"])
export function toolHref(link) {
const path = link.split("?")[0]
@@ -196,7 +196,7 @@ export const Logs = {
</div>
<pre ref="tmuxtail" class="gx-terminal" @scroll.passive="onTmuxScroll">{{ stream.state.log || '(waiting for log output…)' }}</pre>
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" @click="stream.togglePause()">{{ stream.state.paused ? '▶️ Resume' : '⏸️ Pause' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="stream.togglePause()"><i class="bi" :class="stream.state.paused ? 'bi-play-fill' : 'bi-pause-fill'"></i> {{ stream.state.paused ? 'Resume' : 'Pause' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="captureTmux">Capture Log</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllTmux">Delete All</button>
</div>
@@ -0,0 +1,264 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
export const ModelLaboratory = {
name: "ModelLaboratory",
data() {
return {
loading: true,
saving: false,
error: "",
message: "",
dirty: false,
chestnutReady: false,
isOnroad: false,
configuration: { enabled: false, lateralModel: "", longitudinalModel: "" },
runtime: {},
summary: {},
models: [],
}
},
computed: {
readyModels() {
return this.models.filter((m) => m && m.modelLabArtifactAvailable)
},
candidates() {
const ready = this.readyModels
const lat = this.configuration.lateralModel
return ready.filter((m) => !lat || m.value !== lat)
},
selectionError() {
if (!this.chestnutReady) return "Connect a firmware-ready Chestnut first."
if (this.isOnroad) return "Park before changing the laboratory pair."
const lat = this.modelById(this.configuration.lateralModel)
const lon = this.modelById(this.configuration.longitudinalModel)
if (!lat || !lon) return "Choose two small models with published Chestnut artifacts."
if (lat.value === lon.value) return "Lateral and longitudinal models must be different."
if (!lat.modelLabArtifactAvailable || !lon.modelLabArtifactAvailable) {
return "Both models need a precompiled AMD artifact in the manifest."
}
if (!lat.modelLabArtifactInstalled || !lon.modelLabArtifactInstalled) {
return "Prepare both precompiled AMD artifacts first."
}
return ""
},
runtimeState() {
const r = this.runtime || {}
return r.active ? "Pair active" : r.requested ? "Pair requested" : "Inactive"
},
},
created() {
this.poll = usePolling(() => this.refresh(), { interval: 5000 })
this.poll.start()
},
beforeUnmount() {
this.poll?.destroy()
},
methods: {
modelById(id) {
return this.models.find((m) => m.value === id)
},
modelLabel(id) {
return this.modelById(id)?.label || id || "not selected"
},
artifactStatus(m) {
if (m.modelLabArtifactInstalled) return { text: "AMD ready", good: true }
if (m.modelLabArtifactAvailable) return { text: "AMD download needed", good: false }
return { text: "AMD not published", good: false }
},
async refresh() {
try {
const payload = await api.getModelLab()
this.applyPayload(payload)
} catch (e) {
this.error = e?.message || String(e)
} finally {
this.loading = false
}
},
applyPayload(payload) {
payload = payload || {}
this.chestnutReady = Boolean(payload.chestnutReady)
this.isOnroad = Boolean(payload.isOnroad)
this.error = String(payload.configurationError || "")
this.runtime = payload.runtime && typeof payload.runtime === "object" ? payload.runtime : {}
this.summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {}
this.models = Array.isArray(payload.models) ? payload.models : []
const cfg = payload.configuration && typeof payload.configuration === "object" ? payload.configuration : {}
const draft = { ...this.configuration }
this.configuration = {
enabled: Boolean(cfg.enabled),
lateralModel: this.dirty ? draft.lateralModel : String(cfg.lateralModel || ""),
longitudinalModel: this.dirty ? draft.longitudinalModel : String(cfg.longitudinalModel || ""),
}
this.normalizeSelection()
},
normalizeSelection() {
const ready = this.readyModels
if (!this.modelById(this.configuration.lateralModel) && ready.length) {
this.configuration.lateralModel = ready[0].value
}
if (!this.modelById(this.configuration.longitudinalModel) && ready.length > 1) {
const lon = ready.find((m) => m.value !== this.configuration.lateralModel)
this.configuration.longitudinalModel = lon?.value || ""
}
},
onLateralChange() {
this.dirty = true
const lon = this.modelById(this.configuration.longitudinalModel)
const lat = this.modelById(this.configuration.lateralModel)
if (lon && lat && lon.value === lat.value) {
this.configuration.longitudinalModel = this.candidates[0]?.value || ""
}
},
async save(enabled) {
if (this.saving) return
if (enabled && this.selectionError) {
this.error = this.selectionError
return
}
this.saving = true
this.error = ""
this.message = ""
try {
const payload = await api.saveModelLab({
enabled,
lateralModel: this.configuration.lateralModel,
longitudinalModel: this.configuration.longitudinalModel,
})
this.dirty = false
this.applyPayload(payload)
this.message = String(payload?.message || "Model Laboratory configuration saved.")
showSnackbar("Model Laboratory saved", "info")
} catch (e) {
this.error = e?.message || String(e)
} finally {
this.saving = false
}
},
async prepareModel(modelId) {
if (this.saving || !modelId) return
this.saving = true
this.error = ""
this.message = ""
try {
const payload = await api.prepareModelLabArtifact(modelId)
this.message = String(payload?.message || "Chestnut artifact download queued.")
showSnackbar("Chestnut artifact download queued", "info")
await this.refresh()
} catch (e) {
this.error = e?.message || String(e)
} finally {
this.saving = false
}
},
},
template: `
<div class="gx-view">
<div v-if="loading" class="gx-card"><div class="gx-loading">Loading laboratory status...</div></div>
<template v-else>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-bezier2"></i>
<span class="gx-section__title">Model Laboratory</span>
<span class="gx-chip" :style="chestnutReady ? 'color:var(--success);' : 'color:var(--warning);'">{{ chestnutReady ? 'Chestnut ready' : 'Chestnut required' }}</span>
<span class="gx-chip" :style="isOnroad ? 'color:var(--warning);' : 'color:var(--success);'">{{ isOnroad ? 'Onroad · locked' : 'Parked · configurable' }}</span>
</div>
<div style="padding: 0 var(--sp-4) var(--sp-3); color:var(--text-muted); font-size:var(--fs-sm);">
Use the lateral judgment of one small model and the longitudinal judgment of another.
</div>
<div v-if="error" class="gx-alert" style="margin:0 var(--sp-4) var(--sp-3);">
<i class="bi bi-exclamation-triangle-fill" style="color:var(--error);"></i>
<div class="gx-alert__body"><span style="color:var(--error);">{{ error }}</span></div>
</div>
<div v-if="message" class="gx-alert gx-alert--info" style="margin:0 var(--sp-4) var(--sp-3);">
<i class="bi bi-check-circle-fill"></i>
<div class="gx-alert__body"><span>{{ message }}</span></div>
</div>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-collection"></i>
<span class="gx-section__title">Compose a pair</span>
<span class="gx-chip" :style="configuration.enabled ? 'background:var(--success);color:var(--on-secondary);' : ''">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span>
</div>
<div style="padding: var(--sp-4); display:grid; gap:var(--sp-3);">
<label style="display:grid; gap:4px;">
<strong style="font-size:var(--fs-sm);">Lateral model</strong>
<small style="color:var(--text-muted); font-size:var(--fs-xs);">Path shape, curvature, lane geometry, and driving desire</small>
<select class="gx-field" :value="configuration.lateralModel" @change="configuration.lateralModel = $event.target.value; onLateralChange()">
<option value="">Choose a model</option>
<option v-for="m in readyModels" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option>
</select>
</label>
<label style="display:grid; gap:4px;">
<strong style="font-size:var(--fs-sm);">Longitudinal model</strong>
<small style="color:var(--text-muted); font-size:var(--fs-xs);">Speed, acceleration, stopping, leads, and scene confidence</small>
<select class="gx-field" :value="configuration.longitudinalModel" @change="configuration.longitudinalModel = $event.target.value; dirty = true">
<option value="">Choose a model</option>
<option v-for="m in candidates" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option>
</select>
</label>
<div style="display:flex; flex-wrap:wrap; gap:6px; align-items:center;">
<strong>{{ modelLabel(configuration.lateralModel) }}</strong><span class="gx-row__desc" style="margin:0;">steers</span>
<i class="bi bi-arrow-left-right"></i>
<strong>{{ modelLabel(configuration.longitudinalModel) }}</strong><span class="gx-row__desc" style="margin:0;">paces</span>
</div>
<p v-if="selectionError" class="gx-row__desc" style="margin:0; color:var(--warning);">{{ selectionError }}</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="gx-btn" :disabled="saving || !!selectionError" @click="save(true)">
<i class="bi bi-play-fill"></i> Enable for next drive
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad || !configuration.enabled" @click="save(false)">
<i class="bi bi-stop-fill"></i> Disable
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="saving" @click="refresh">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-activity"></i>
<span class="gx-section__title">Runtime</span>
<span class="gx-chip" :style="runtime.active ? 'background:var(--success);color:var(--on-secondary);' : ''">{{ runtimeState }}</span>
</div>
<div style="padding: var(--sp-4); display:grid; gap:6px;">
<div class="gx-row" style="border-top:none;"><span class="gx-row__label">Lateral</span><span class="gx-row__value">{{ modelLabel(runtime.lateralModel) }}</span></div>
<div class="gx-row" style="border-top:none;"><span class="gx-row__label">Longitudinal</span><span class="gx-row__value">{{ modelLabel(runtime.longitudinalModel) }}</span></div>
<div v-if="runtime.error" class="gx-alert" style="margin:0;"><i class="bi bi-exclamation-triangle-fill" style="color:var(--error);"></i><div class="gx-alert__body"><span style="color:var(--error);">{{ runtime.error }}</span></div></div>
<p class="gx-row__desc" style="margin:0;">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p>
</div>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Available models</span>
<span class="gx-section__count">{{ summary.ready || 0 }} ready to pair · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download</span>
</div>
<article v-for="m in readyModels" :key="m.value" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ m.label }}</span>
<span class="gx-row__desc">{{ m.value }} · {{ m.series || 'Unknown series' }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
<span class="gx-chip">{{ m.version || 'unknown version' }}</span>
<span class="gx-chip">{{ m.modelSize || 'small' }}</span>
<span class="gx-chip" :style="artifactStatus(m).good ? 'color:var(--success);' : 'color:var(--warning);'">{{ artifactStatus(m).text }}</span>
<button v-if="m.modelLabArtifactAvailable && !m.modelLabArtifactInstalled" type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad" @click="prepareModel(m.value)">
Prepare for Chestnut
</button>
</div>
</article>
<div style="padding: var(--sp-3);">
<p class="gx-row__desc" style="margin:0;">Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma. A normal installed model may still need its separate Chestnut artifact.</p>
</div>
</div>
</template>
</div>
`,
}
@@ -59,6 +59,8 @@ export const Pip = {
mounted() {
this._img = null
this._sizedFor = null
const canvas = this.canvas()
if (canvas) { canvas.width = CANVAS_W; canvas.height = CANVAS_H }
this.loadExistingConfig()
this.loadSnapshot()
this.$nextTick(() => this.redraw())
@@ -70,6 +72,14 @@ export const Pip = {
canvas() {
return this.$refs?.canvas || null
},
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))
},
canvasScale() {
const canvas = this.canvas()
const img = this._img
@@ -165,8 +175,7 @@ export const Pip = {
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))
this.sizeCanvas()
} else if (!img && (canvas.width === 0 || this._sizedFor)) {
this._sizedFor = null
canvas.width = CANVAS_W
@@ -257,6 +266,7 @@ export const Pip = {
this._sizedFor = null
this.image = true
this.success = "Camera snapshot loaded. Place a center point on each window, then adjust the zoom."
this.sizeCanvas()
this.applyConfigToCanvas()
this.redraw()
if (cleanup) cleanup()
@@ -1,7 +1,8 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { GxNotice } from "../components/GxNotice.js"
import { isFirestarOrigin } from "../components/PwaInstallSection.js"
function fmtDuration(seconds) {
seconds = Number(seconds) || 0
@@ -16,6 +17,26 @@ function formatBytes(bytes) {
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`
}
function getOrdinalSuffix(n) {
const s = ["th", "st", "nd", "rd"]
const v = n % 100
return s[(v - 20) % 10] || s[v] || s[0]
}
function formatScreenDate(dateString) {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return String(dateString || "Unknown date")
const month = date.toLocaleString("en-US", { month: "long" })
const day = date.getDate()
const year = date.getFullYear()
let hour = date.getHours()
const minute = date.getMinutes()
const ampm = hour >= 12 ? "pm" : "am"
hour = hour % 12 || 12
const minuteStr = minute < 10 ? "0" + minute : minute
return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}`
}
function normalizeRoute(r) {
const name = String(r?.name || "")
const isCustomName = !!r?.isCustomName
@@ -33,9 +54,10 @@ function normalizeRoute(r) {
export const Recordings = {
name: "Recordings",
components: { GalaxySection, GalaxyTabs },
components: { GalaxyTabs, GxNotice },
data() {
return {
sub: "routes",
loading: true,
error: "",
routes: [],
@@ -52,6 +74,13 @@ export const Recordings = {
selectedCamera: "",
logsRoute: null,
logsData: null,
onFirestar: isFirestarOrigin(),
// Screen recordings subtab
screenLoading: false,
screenError: "",
screenProgress: 0,
recordings: [],
recPlay: null,
}
},
computed: {
@@ -81,6 +110,13 @@ export const Recordings = {
methods: {
fmtDuration,
formatBytes,
setSub(key) {
this.sub = key === "screen" ? "screen" : "routes"
if (this.sub === "screen" && !this.recordings.length && !this.screenLoading) this.loadScreenRecordings()
},
screenDisplayName(rec) {
return rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenDate(rec.timestamp)
},
async loadRoutes() {
this.loading = true
this.error = ""
@@ -107,6 +143,35 @@ export const Recordings = {
this.loading = false
}
},
async loadScreenRecordings() {
this.screenLoading = true
this.screenError = ""
this.recordings = []
this.screenProgress = 0
const seen = new Set()
try {
this.recController?.abort()
this.recController = new AbortController()
await api.screenRecordingsStream({
signal: this.recController.signal,
onProgress: (p) => { this.screenProgress = p },
onRecordings: (raw) => {
for (const r of raw) {
if (seen.has(r.filename)) continue
seen.add(r.filename)
this.recordings.push(r)
}
},
})
} catch (e) {
if (e?.name !== "AbortError") this.screenError = "Couldn't load recordings."
} finally {
this.screenLoading = false
}
},
refreshScreenRecordings() {
this.loadScreenRecordings()
},
async deleteRoute(route) {
if (!(await GalaxyConfirm({ title: "Delete route?", message: `Delete “${route.displayName}”?`, confirmLabel: "Delete", danger: true }))) return
try {
@@ -148,13 +213,14 @@ export const Recordings = {
this.routes = []
showSnackbar(payload?.message || "Routes deleted!")
} catch (e) {
showSnackbar("Failed to delete routes.", "error")
showSnackbar(e?.message || "Failed to delete routes.", "error")
}
},
async openPlayer(route) {
this.playerRoute = route
this.playerLoading = true
this.playerError = ""
this._playRetries = 0
try {
const data = await api.getRoute(route.name)
const segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter((u) => typeof u === "string") : []
@@ -173,13 +239,23 @@ export const Recordings = {
}
},
cameraUrl(url, low) {
if (this.selectedCamera === "forward") return low && !url.includes("?" ) ? `${url}?quality=low` : url
if (this.selectedCamera === "forward") return low && !url.includes("?") ? `${url}?quality=low` : url
const sep = url.includes("?") ? "&" : "?"
return `${url}${sep}camera=${encodeURIComponent(this.selectedCamera)}${low ? "&quality=low" : ""}`
},
playSegment() {
const video = this.$refs.player
if (!video || !this.segments[this.current]) return
if (!this.segments[this.current]) return
// The player mounts inside a Teleport + transition after openPlayer clears
// playerLoading, so the video element may not exist on the very first call.
if (!video) {
this._playRetries = (this._playRetries || 0) + 1
if (this._playRetries <= 15) {
requestAnimationFrame(() => this.playSegment())
}
return
}
this._playRetries = 0
video.src = this.cameraUrl(this.segments[this.current])
video.load()
video.play().catch(() => {})
@@ -192,6 +268,7 @@ export const Recordings = {
a.click()
},
closePlayer() {
this._playRetries = 0
if (this.$refs.player) { this.$refs.player.pause(); this.$refs.player.removeAttribute("src") }
this.playerRoute = null
this.playerLoading = false
@@ -207,13 +284,71 @@ export const Recordings = {
showSnackbar("Could not read logs.", "error")
}
},
closeRecPlayer() {
this.recPlay = null
},
screenUrl(filename) {
return api.screenRecordingVideoUrl(filename)
},
playRec(rec) {
this.recPlay = rec
},
downloadRec(rec) {
const a = document.createElement("a")
a.href = api.screenRecordingVideoUrl(rec.filename)
a.download = rec.filename
a.click()
},
async renameRec(rec) {
const base = rec.filename.replace(/\.mp4$/i, "")
const val = prompt("Rename recording:", base)
if (!val || val === base) return
try {
await api.renameScreenRecording(rec.filename, val + ".mp4")
showSnackbar("Recording renamed!")
this.refreshScreenRecordings()
} catch (e) {
showSnackbar("Rename failed.", "error")
}
},
async deleteRec(rec) {
if (!(await GalaxyConfirm({ title: "Delete recording?", message: `Delete “${rec.filename}”?`, confirmLabel: "Delete", danger: true }))) return
try {
await api.deleteScreenRecording(rec.filename)
this.recordings = this.recordings.filter((r) => r.filename !== rec.filename)
if (this.recPlay?.filename === rec.filename) this.recPlay = null
showSnackbar("Recording deleted!")
} catch (e) {
showSnackbar("Delete failed.", "error")
}
},
async deleteAllRecs() {
if (!(await GalaxyConfirm({ title: "Delete all recordings?", message: "This permanently deletes every screen recording.", confirmLabel: "Delete All", danger: true }))) return
try {
const payload = await api.deleteAllScreenRecordings()
this.recordings = []
this.recPlay = null
showSnackbar(payload?.message || "All screen recordings deleted!")
} catch (e) {
showSnackbar(e?.message || "Delete failed.", "error")
}
},
},
async mounted() {
if (!this.onFirestar) await this.loadRoutes()
},
beforeUnmount() {
this.controller?.abort()
this.recController?.abort()
},
async mounted() { await this.loadRoutes() },
beforeUnmount() { this.controller?.abort() },
template: `
<div>
<template v-if="!onFirestar">
<h2 style="margin-top:0;">Recordings</h2>
<GalaxyTabs :items="{ routes: 'Dashcam Routes', screen: 'Screen Recordings' }" :active="sub" @select="setSub" />
<template v-if="sub === 'routes'">
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-camera-reels"></i>
@@ -277,10 +412,47 @@ export const Recordings = {
<a class="gx-btn gx-btn--tonal" :href="seg.url" download>Download</a>
</div>
</div>
</template>
<template v-else>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-record-circle"></i>
<span class="gx-section__title">Screen Recordings</span>
<span class="gx-section__count">{{ recordings.length }}</span>
</div>
<div v-if="screenLoading && !recordings.length" class="gx-loading">Loading screen recordings...</div>
<div v-else-if="screenError" class="gx-empty" style="color: var(--error);">{{ screenError }}</div>
<div v-else-if="!recordings.length" class="gx-empty">No screen recordings found.</div>
<article v-for="r in recordings" :key="r.filename" class="gx-row" style="cursor:pointer;" @click="playRec(r)">
<img :src="r.png" alt="" loading="lazy" style="width:84px; height:auto; border-radius:var(--radius-sm); object-fit:cover; flex:none;">
<div class="gx-row__info">
<span class="gx-row__label">{{ screenDisplayName(r) }}</span>
<span class="gx-row__desc">{{ r.filename }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" title="Play" @click.stop="playRec(r)"><i class="bi bi-play-fill"></i></button>
<button type="button" class="gx-btn gx-btn--tonal" title="Rename" @click.stop="renameRec(r)"><i class="bi bi-pencil"></i></button>
<button type="button" class="gx-btn gx-btn--tonal" title="Download" @click.stop="downloadRec(r)"><i class="bi bi-download"></i></button>
<button type="button" class="gx-btn gx-btn--danger" title="Delete" @click.stop="deleteRec(r)"><i class="bi bi-trash"></i></button>
</div>
</article>
</section>
<section class="gx-card" v-if="recordings.length">
<div class="gx-section__header">
<i class="bi bi-exclamation-triangle"></i>
<span class="gx-section__title">Delete recordings</span>
</div>
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--danger" @click="deleteAllRecs">Delete All Recordings</button>
</div>
</section>
</template>
<Teleport to="body">
<transition name="gx-fade">
<div v-if="playerRoute" class="gx-scrim gx-scrim--bottomsheet" @click.self="closePlayer">
<div v-if="sub === 'routes' && playerRoute" class="gx-scrim gx-scrim--bottomsheet" @click.self="closePlayer">
<div class="gx-sheet" role="dialog" aria-label="Route video player">
<div class="gx-section__header" style="cursor:default;">
<i class="bi bi-camera-video"></i>
@@ -307,6 +479,32 @@ export const Recordings = {
</div>
</transition>
</Teleport>
<Teleport to="body">
<transition name="gx-fade">
<div v-if="recPlay" class="gx-scrim gx-scrim--bottomsheet" @click.self="closeRecPlayer">
<div class="gx-sheet" role="dialog" aria-label="Screen recording player">
<div class="gx-section__header" style="cursor:default;">
<i class="bi bi-record-circle"></i>
<span class="gx-section__title">{{ screenDisplayName(recPlay) }}</span>
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closeRecPlayer"><i class="bi bi-x-lg"></i></button>
</div>
<div style="padding: var(--sp-3);">
<video class="gx-video" controls autoplay playsinline :src="screenUrl(recPlay.filename)"></video>
<div style="display:flex; gap:8px; padding: var(--sp-3) 0 0; flex-wrap:wrap;">
<button type="button" class="gx-btn" @click="downloadRec(recPlay)"><i class="bi bi-download"></i> Download</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="renameRec(recPlay)"><i class="bi bi-pencil"></i> Rename</button>
<button type="button" class="gx-btn gx-btn--danger" @click="deleteRec(recPlay)"><i class="bi bi-trash"></i> Delete</button>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<GxNotice v-else tone="info" icon="bi-satellite" title="Recordings Unavailable via Galaxy"
text="Loading recordings requires a direct connection. Connect to your device's local network to use this feature." />
</div>
`,
}
@@ -267,7 +267,7 @@ export const SystemTools = {
<div v-if="fastStatus.message" class="gx-note">{{ fastStatus.message }}</div>
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ fastStatus.warning }}</div>
<div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;">
<div v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-note gx-note--danger"> {{ w }}</div>
<div v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-note gx-note--danger"><i class="bi bi-exclamation-triangle-fill"></i> {{ w }}</div>
</div>
</div>
</div>
@@ -398,10 +398,10 @@ export const Vasm = {
</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 class="gx-chip" :style="'border-color:' + BLUE + '; color:' + BLUE + ';'">Left: {{ leftPoints.length }} pt{{ leftPoints.length !== 1 ? 's' : '' }}<i v-if="leftDone" class="bi bi-check-lg"></i>
<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 class="gx-chip" :style="'border-color:' + ORANGE + '; color:' + ORANGE + ';'">Right: {{ rightPoints.length }} pt{{ rightPoints.length !== 1 ? 's' : '' }}<i v-if="rightDone" class="bi bi-check-lg"></i>
<span v-if="rightPoints.length > 0" style="margin-left:6px; cursor:pointer; color:var(--error);" @click="clearSide('right')">×</span>
</span>
</div>
@@ -1,6 +1,6 @@
{
"name": "Galaxy",
"short_name": "Galaxy",
"name": "Big Dipper",
"short_name": "Big Dipper",
"description": "Control and configure your openpilot device from anywhere.",
"icons": [
{ "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
@@ -325,15 +325,17 @@ def test_ui_galaxy_background_is_css_only_and_lightweight():
def test_galaxy_py_serves_classic_at_root_and_new_ui_at_mobile():
source = GALAXY_PY.read_text(encoding="utf-8")
# The classic Galaxy SPA is the default landing at / (original behaviour).
# The classic Galaxy SPA is the default landing at / (original behaviour) unless
# the "New Galaxy by Default" (GalaxyMobileDefault) toggle is enabled.
assert '@app.route("/", methods=["GET"])' in source
assert 'render_template("index.html")' in source
assert 'params.get_bool("GalaxyMobileDefault")' in source
# Classic also stays reachable at /classic (page-in-page embed target).
assert '@app.route("/classic", methods=["GET"])' in source
# The modern Vue UI is served at /mobile (and /ui), not the root.
# The modern Vue UI is served at /mobile, not the root.
assert '@app.route("/mobile", methods=["GET"])' in source
assert '@app.route("/ui", methods=["GET"])' in source
assert 'Path(app.static_folder) / "mobile" / "index.html"' in source
assert '@app.route("/ui", methods=["GET"])' not in source
def test_ui_manifest_is_valid_pwa_manifest():
+2 -2
View File
@@ -5075,6 +5075,8 @@ def setup(app):
@app.route("/", methods=["GET"])
def index():
if params.get_bool("GalaxyMobileDefault"):
return _serve_new_ui()
response = make_response(render_template("index.html"))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
@@ -5088,8 +5090,6 @@ def setup(app):
@app.route("/mobile", methods=["GET"])
@app.route("/mobile/", methods=["GET"])
@app.route("/ui", methods=["GET"])
@app.route("/ui/", methods=["GET"])
def mobile_index():
return _serve_new_ui()