mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-06 16:13:48 +08:00
big dipper
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user