This commit is contained in:
firestar5683
2026-09-14 13:13:33 -05:00
parent 71649a2ac1
commit 200ac08499
6 changed files with 281 additions and 26 deletions
@@ -1275,6 +1275,8 @@ button.gx-chip:hover {
transition: transform var(--motion-slow);
width: 84vw;
z-index: calc(var(--z-drawer) + 1);
display: flex;
flex-direction: column;
}
[data-theme="light"] .gx-drawer {
@@ -1724,6 +1726,46 @@ button.gx-chip:hover {
.gx-nav-item i { font-size: 1.3rem; width: 24px; }
.gx-device-picker {
border-top: 1px solid var(--glass-border);
margin-top: auto;
padding-top: var(--sp-3);
}
.gx-device-picker__heading {
align-items: center;
display: flex;
justify-content: space-between;
padding: 0 var(--sp-2) var(--sp-1);
}
.gx-device-picker__heading .gx-nav-section__title {
padding: 0;
}
.gx-device-picker__heading-icon {
color: var(--primary);
font-size: var(--fs-sm);
padding-right: var(--sp-2);
}
.gx-device-picker__hint {
color: var(--text-muted);
font-size: var(--fs-xs);
padding: 0 var(--sp-2);
}
.gx-device-picker__row {
align-items: center;
display: flex;
gap: var(--sp-1);
}
.gx-device-picker__item {
flex: 1;
min-width: 0;
}
.gx-device-picker__name {
min-width: 0;
overflow: hidden;
@@ -1742,6 +1784,67 @@ button.gx-chip:hover {
white-space: nowrap;
}
.gx-device-picker__rename {
color: var(--text-muted);
flex: 0 0 auto;
height: 36px;
width: 36px;
}
.gx-device-picker__rename:hover {
color: var(--primary);
}
.gx-device-picker__editor {
background: var(--surface-container);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
margin: var(--sp-2) var(--sp-2) 0;
padding: var(--sp-3);
}
.gx-device-picker__editor-label {
color: var(--text-muted);
display: block;
font-size: var(--fs-xs);
margin-bottom: var(--sp-2);
}
.gx-device-picker__editor-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.gx-device-picker__editor input {
background: var(--surface-container-high);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
color: var(--on-surface);
flex: 1 1 100%;
font: inherit;
min-width: 0;
padding: var(--sp-2);
}
.gx-device-picker__editor button {
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
padding: var(--sp-2) var(--sp-3);
}
.gx-device-picker__save {
background: var(--primary);
color: var(--on-primary);
}
.gx-device-picker__cancel {
background: transparent;
color: var(--on-surface);
}
.gx-scrim {
align-items: center;
background: rgba(0, 0, 0, 0.6);
@@ -157,7 +157,6 @@ export const AppShell = {
<i class="bi" :class="navPinned ? 'bi-pin-angle-fill' : 'bi-pin-angle'"></i>
</button>
</div>
<DevicePicker />
<div class="gx-nav-section">
<div class="gx-nav-section__title">{{ tr("Main") }}</div>
<a class="gx-nav-item" :class="{ active: isActive('/') }" @click.prevent="navTo('/')">
@@ -176,6 +175,7 @@ export const AppShell = {
<i class="bi" :class="link.icon"></i><span>{{ tr(link.name, link.name) }}</span>
</a>
</div>
<DevicePicker />
</aside>
<main class="gx-content">
@@ -1,4 +1,6 @@
const SLUG_RE = /^[A-Za-z0-9]{16}$/
const DEVICE_NAMES_KEY = "galaxy-device-names"
const MAX_NAME_LENGTH = 40
export const DevicePicker = {
name: "DevicePicker",
@@ -6,14 +8,40 @@ export const DevicePicker = {
return {
devices: [],
activeSlug: "",
customNames: {},
draftName: "",
editingSlug: "",
loading: true,
}
},
computed: {
hasMultipleDevices() { return this.devices.length > 1 },
hasDevices() { return this.devices.length > 0 },
},
methods: {
loadCustomNames() {
try {
const saved = JSON.parse(localStorage.getItem(DEVICE_NAMES_KEY) || "{}")
if (saved && typeof saved === "object" && !Array.isArray(saved)) {
this.customNames = Object.fromEntries(Object.entries(saved).filter(([slug, name]) => (
SLUG_RE.test(slug) && typeof name === "string" && name.trim()
)))
}
} catch (error) {
this.customNames = {}
}
},
saveCustomNames() {
try {
localStorage.setItem(DEVICE_NAMES_KEY, JSON.stringify(this.customNames))
} catch (error) {
// Private browsing can disable localStorage; the current name still works.
}
},
displayName(device, index) {
return this.customNames[device.slug] || device.name || `Comma ${index + 1}`
},
async loadDevices() {
this.loadCustomNames()
try {
const response = await fetch("/_gateway/devices", { cache: "no-store" })
if (!response.ok) return
@@ -28,6 +56,30 @@ export const DevicePicker = {
this.loading = false
}
},
startRename(device, index) {
this.editingSlug = device.slug
this.draftName = this.displayName(device, index)
this.$nextTick(() => {
const input = this.$refs.deviceNameInput
;(Array.isArray(input) ? input[0] : input)?.focus()
})
},
cancelRename() {
this.editingSlug = ""
this.draftName = ""
},
saveRename(device) {
if (!device) return
const name = this.draftName.trim().slice(0, MAX_NAME_LENGTH)
if (name) this.customNames = { ...this.customNames, [device.slug]: name }
else {
const nextNames = { ...this.customNames }
delete nextNames[device.slug]
this.customNames = nextNames
}
this.saveCustomNames()
this.cancelRename()
},
selectDevice(device) {
if (!device?.path || device.slug === this.activeSlug) return
window.location.assign(device.path)
@@ -37,16 +89,35 @@ export const DevicePicker = {
this.loadDevices()
},
template: `
<div v-if="!loading && hasMultipleDevices" class="gx-nav-section gx-device-picker">
<div class="gx-nav-section__title">Commas</div>
<a v-for="device in devices" :key="device.slug" class="gx-nav-item gx-device-picker__item"
:class="{ active: device.slug === activeSlug }" :href="device.path"
:aria-current="device.slug === activeSlug ? 'page' : undefined"
@click.prevent="selectDevice(device)">
<i class="bi bi-cpu"></i>
<span class="gx-device-picker__name">{{ device.name }}</span>
<span v-if="device.slug === activeSlug" class="gx-device-picker__current">Current</span>
</a>
<div v-if="!loading && hasDevices" class="gx-device-picker">
<div class="gx-device-picker__heading">
<div>
<div class="gx-nav-section__title">Commas</div>
<div class="gx-device-picker__hint">Switch device</div>
</div>
<i class="bi bi-arrow-left-right gx-device-picker__heading-icon" aria-hidden="true"></i>
</div>
<div v-for="(device, index) in devices" :key="device.slug" class="gx-device-picker__row">
<a class="gx-nav-item gx-device-picker__item" :class="{ active: device.slug === activeSlug }" :href="device.path"
:aria-current="device.slug === activeSlug ? 'page' : undefined"
@click.prevent="selectDevice(device)">
<i class="bi bi-cpu"></i>
<span class="gx-device-picker__name">{{ displayName(device, index) }}</span>
<span v-if="device.slug === activeSlug" class="gx-device-picker__current">Current</span>
</a>
<button type="button" class="gx-icon-btn gx-device-picker__rename" :aria-label="'Rename ' + displayName(device, index)"
:title="'Rename ' + displayName(device, index)" @click="startRename(device, index)">
<i class="bi bi-pencil" aria-hidden="true"></i>
</button>
</div>
<form v-if="editingSlug" class="gx-device-picker__editor" @submit.prevent="saveRename(devices.find((device) => device.slug === editingSlug))">
<label class="gx-device-picker__editor-label" for="gx-device-name">Rename comma</label>
<div class="gx-device-picker__editor-row">
<input id="gx-device-name" ref="deviceNameInput" v-model="draftName" maxlength="40" autocomplete="off" autofocus />
<button type="submit" class="gx-device-picker__save">Save</button>
<button type="button" class="gx-device-picker__cancel" @click="cancelRename">Cancel</button>
</div>
</form>
</div>
`,
}
@@ -12,7 +12,7 @@ import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { DevModeBanner } from "../components/DevModeBanner.js"
import { LanguageSelector } from "../components/LanguageSelector.js"
import { setLanguage, t } from "../i18n.js"
import { languageState, setLanguage, t } from "../i18n.js"
const LEGACY_PERSONALITY_KEYS = new Set([
"AccelerationProfile", "AggressiveFollow", "AggressiveFollowHigh", "CustomAccelProfile",
@@ -22,6 +22,8 @@ const LEGACY_PERSONALITY_KEYS = new Set([
"StandardFollowHigh", "TrafficFollow", "TruckTuning",
])
const LANGUAGE_SECTION_SLUG = "language"
export const Settings = {
name: "Settings",
components: { SettingTree, PersonalityProfiles, GalaxyToggleCard, GalaxySection, DevModeBanner, LongitudinalMode, LanguageSelector },
@@ -53,6 +55,7 @@ export const Settings = {
},
hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) },
searchActive() { return !!this.searchTerm },
currentLanguage() { return languageState.code },
searchTerm: {
get() { return store.search },
set(v) { store.search = v },
@@ -114,9 +117,16 @@ export const Settings = {
},
applyRouteSection() {
const route = store.route
if (route === "/settings") {
if (this.activeSectionSlug === LANGUAGE_SECTION_SLUG) {
const preferred = this.sections.find((s) => s.slug === this.defaultSectionSlug)
this.activeSectionSlug = (preferred || this.sections[0])?.slug || ""
}
return
}
if (!route.startsWith("/settings/")) return
const slug = route.replace(/^\/settings\/?/, "").split("?")[0].split("/")[0]
if (slug && this.sections.some((s) => s.slug === slug)) this.activeSectionSlug = slug
if (slug === LANGUAGE_SECTION_SLUG || this.sections.some((s) => s.slug === slug)) this.activeSectionSlug = slug
if (store.params.open) this.expanded = { ...this.expanded, [store.params.open]: true }
},
lockReason(param) {
@@ -169,13 +179,20 @@ export const Settings = {
<div v-else>
<div class="gx-tabs" style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;">
<button v-for="s in sections" :key="s.slug" type="button"
class="gx-chip" :style="s.slug === activeSection.slug ? 'background: var(--primary); color: var(--on-primary);' : 'background: var(--surface-variant); color: var(--on-surface-variant); cursor:pointer;'"
class="gx-chip" :style="s.slug === activeSectionSlug ? 'background: var(--primary); color: var(--on-primary);' : 'background: var(--surface-variant); color: var(--on-surface-variant); cursor:pointer;'"
@click="selectSection(s.slug)">
{{ tr(s.name, s.name) }}
</button>
<button type="button" class="gx-chip"
:style="activeSectionSlug === 'language' ? 'background: var(--primary); color: var(--on-primary);' : 'background: var(--surface-variant); color: var(--on-surface-variant); cursor:pointer;'"
@click="selectSection('language')">
{{ tr("Language") }}
</button>
</div>
<div class="gx-card">
<LanguageSelector v-if="activeSectionSlug === 'language'" :device-value="currentLanguage" />
<div v-else class="gx-card">
<div class="gx-section__header">
<i class="bi" :class="activeSection.icon"></i>
<span class="gx-section__title">{{ tr(activeSection.name, activeSection.name) }}</span>
@@ -189,8 +206,6 @@ export const Settings = {
</template>
<div v-else class="gx-empty">{{ tr("No settings available.") }}</div>
<LanguageSelector v-if="route === '/settings' && !loading" :device-value="String(values.LanguageSetting || '')" />
</div>
`,
}
@@ -17,6 +17,27 @@ function toPercent(value) {
}
const CORE_UPDATE_BRANCHES = ["StarPilot", "Dom"]
const REBOOT_PENDING_STORAGE_KEY = "galaxy-update-reboot-pending"
function readRebootMarker() {
try {
const raw = localStorage.getItem(REBOOT_PENDING_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
const startedAt = Number(parsed?.startedAt)
return Number.isFinite(startedAt) && startedAt > 0 ? startedAt : null
} catch (e) {
return null
}
}
function writeRebootMarker(startedAt) {
try { localStorage.setItem(REBOOT_PENDING_STORAGE_KEY, JSON.stringify({ startedAt })) } catch (e) {}
}
function clearRebootMarker() {
try { localStorage.removeItem(REBOOT_PENDING_STORAGE_KEY) } catch (e) {}
}
export const SystemTools = {
name: "SystemTools",
@@ -45,6 +66,11 @@ export const SystemTools = {
isOnroad: false,
fastStatus: null,
statusUnavailable: false,
rebootPending: !!readRebootMarker(),
rebootStartedAt: readRebootMarker() || 0,
rebootOfflineSeen: false,
reconnectedNotice: false,
checkedForUpdates: false,
busy: "",
autoUpdateBusy: false,
@@ -80,8 +106,8 @@ export const SystemTools = {
return this.branchLoading || this.isOnroad || !!this.fastStatus?.isOnroad || this.updateInProgress || !!this.busy
},
statusRebooting() { return String(this.fastStatus?.stage || "").trim().toLowerCase() === "rebooting" },
updateInProgress() { return !!this.fastStatus?.running || this.statusRebooting },
statusPollingNeeded() { return !this.fastStatus || this.updateInProgress },
updateInProgress() { return !!this.fastStatus?.running || this.statusRebooting || this.rebootPending },
statusPollingNeeded() { return !this.fastStatus || this.updateInProgress || this.rebootPending },
versionChoices() { return this.targetBranch === "StarPilot" ? releaseVersions(this.versionCommits) : this.versionCommits },
installVersionBlocked() {
return this.branchSwitchBlocked || this.branchBusy || !this.branches.includes(this.targetBranch) ||
@@ -145,13 +171,42 @@ export const SystemTools = {
try {
const status = await api.getUpdateFastStatus()
if (!status) throw new Error("Update status unavailable")
const stage = String(status.stage || "").trim().toLowerCase()
if (stage === "rebooting" && !this.rebootPending) {
this.rebootPending = true
this.rebootStartedAt = Date.now()
writeRebootMarker(this.rebootStartedAt)
}
const pendingAge = this.rebootStartedAt ? Date.now() - this.rebootStartedAt : 0
const deviceReturned = this.rebootPending && !status.running && stage !== "rebooting" &&
(this.statusUnavailable || pendingAge >= 30_000)
const updateFailed = this.rebootPending && stage === "error"
this.fastStatus = status
this.statusUnavailable = false
this.isOnroad = !!status.isOnroad
if (deviceReturned) this.clearRebootPending()
else if (updateFailed) this.clearRebootPending(false)
} catch (e) {
this.fastStatus = null
this.statusUnavailable = true
if (this.rebootPending) this.rebootOfflineSeen = true
else this.fastStatus = null
if (throwOnError) throw e
}
},
markRebootPending() {
this.rebootPending = true
this.rebootStartedAt = Date.now()
this.rebootOfflineSeen = false
this.reconnectedNotice = false
writeRebootMarker(this.rebootStartedAt)
},
clearRebootPending(showNotice = true) {
this.rebootPending = false
this.rebootStartedAt = 0
this.rebootOfflineSeen = false
clearRebootMarker()
if (showNotice) this.reconnectedNotice = true
},
async backupToggles() {
try {
const blob = await api.backupToggles()
@@ -228,6 +283,7 @@ export const SystemTools = {
if (!(await GalaxyConfirm({ title: "Reset toggles to default?", message: "This resets all toggles to their default values and reboots.", confirmLabel: "Reset", danger: true }))) return
try {
await api.resetTogglesDefault()
this.markRebootPending()
showSnackbar("Resetting toggles to default... rebooting.")
} catch (e) {
showSnackbar("Reset failed.", "error")
@@ -347,6 +403,7 @@ export const SystemTools = {
return
}
const result = await api.installUpdateVersion(branch, commit)
this.markRebootPending()
showSnackbar(result?.message || `Installing ${version} on ${branch}...`)
await this.loadFastStatus()
} catch (e) {
@@ -420,6 +477,7 @@ export const SystemTools = {
}
const fn = action === "fast" ? api.updateFast : action === "recover" ? api.updateRecover : api.updateRollback
const payload = await fn()
this.markRebootPending()
showSnackbar(payload?.message || "Update started.")
await this.loadFastStatus()
} catch (e) {
@@ -432,6 +490,7 @@ export const SystemTools = {
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()
this.markRebootPending()
showSnackbar("SAVE ME initiated — factory resetting...")
await this.loadFastStatus()
} catch (e) {
@@ -502,22 +561,26 @@ export const SystemTools = {
<div v-if="branchLoading" class="gx-loading">Loading update info...</div>
<template v-else>
<GxNotice v-if="isOnroad" text="Updates and branch switching are only available while offroad." style="margin-bottom:12px;" />
<GxNotice v-if="rebootPending" tone="info" icon="bi-arrow-repeat gx-spin" title="Device rebooting"
:text="statusUnavailable ? 'The device is temporarily offline. Galaxy will keep checking until it reconnects.' : 'The update is complete. Waiting for the device to reconnect…'" />
<GxNotice v-else-if="reconnectedNotice" tone="info" icon="bi-check-circle-fill" title="Device reconnected"
text="Galaxy is connected again and the update status is current." />
<div v-if="fastStatus" class="gx-card" style="margin-bottom:12px;">
<div class="gx-section__header">
<i class="bi bi-arrow-repeat"></i>
<span class="gx-section__title">Update Status</span>
<span v-if="updateInProgress" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ statusRebooting ? 'Reconnecting…' : fastStatus.progressPercent + '%' }}</span>
<span v-if="updateInProgress" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ rebootPending || statusRebooting ? 'Reconnecting…' : fastStatus.progressPercent + '%' }}</span>
<span v-else-if="updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
<span v-else-if="checkedForUpdates" class="gx-chip">Up to date</span>
<span v-else class="gx-chip">Not checked</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Installed branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
<div v-if="updateInProgress" 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="updateInProgress && !rebootPending" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
<div v-if="updateInProgress" class="gx-update-progress" role="progressbar" aria-label="Update progress"
<div v-if="updateInProgress && !rebootPending" class="gx-update-progress" role="progressbar" aria-label="Update progress"
:aria-valuenow="Math.round(fastStatus.progressPercent || 0)" aria-valuemin="0" aria-valuemax="100">
<div class="gx-update-progress__track">
<div class="gx-update-progress__fill" :class="{ 'gx-update-progress__fill--error': fastStatus.stage === 'error' }"
@@ -530,6 +593,7 @@ export const SystemTools = {
<small v-if="fastStatus.progressDetail">{{ fastStatus.progressDetail }}</small>
</div>
<div v-if="fastStatus.message" class="gx-note">{{ fastStatus.message }}</div>
<div v-if="statusUnavailable" class="gx-note">Waiting for the device to reconnect. The last update status is being kept on screen.</div>
<div v-if="fastStatus.warning && (updateInProgress || 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"><i class="bi bi-exclamation-triangle-fill"></i> {{ w }}</div>
@@ -68,9 +68,11 @@ def test_ui_device_picker_uses_gateway_directory_and_preserves_local_galaxy():
shell = _read("js/components/AppShell.js")
assert 'fetch("/_gateway/devices"' in picker
assert "hasMultipleDevices" in picker
assert "localStorage" in picker
assert "Rename comma" in picker
assert "hasDevices" in picker
assert "window.location.assign(device.path)" in picker
assert '<DevicePicker />' in shell
assert shell.index('<DevicePicker />') > shell.index('v-for="(links, section) in NAV"')
assert "Local Galaxy instances do not have the gateway directory endpoint." in picker