This commit is contained in:
firestar5683
2026-03-04 23:34:58 -06:00
parent 9849588374
commit 1e5b1ef5ab
7 changed files with 1474 additions and 1 deletions
@@ -75,4 +75,4 @@
gap: 0.5em 1em;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
}
@@ -17,6 +17,7 @@ import { ModelManager } from "/assets/components/tools/model_manager.js?v=202603
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
import { UpdateManager } from "/assets/components/tools/update_manager.js"
let router, routerState
@@ -45,6 +46,7 @@ function Root() {
createRoute("thememaker", "/theme_maker", ThemeMaker),
createRoute("tmux", "/manage_tmux", TmuxLog),
createRoute("toggles", "/manage_toggles", ToggleControl),
createRoute("updates", "/manage_updates", UpdateManager),
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
]
@@ -23,6 +23,7 @@ const MenuItems = {
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
{ name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" },
{ name: "Software", link: "/manage_updates", icon: "bi-arrow-up-circle" },
{ name: "Vehicle Features", link: "/vehicle_features", icon: "bi-car-front" },
],
};
@@ -0,0 +1,206 @@
.updateManager {
max-width: var(--width-xxl);
}
.updateActions {
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.updateButton {
background: var(--main-fg);
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
}
.updateButton.danger {
background: var(--danger-bg);
}
.updateButton.danger:hover {
background: var(--danger-hover-bg);
}
.updateButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.updateButton:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.updateCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 80%;
padding: var(--border-radius-xl);
}
.updateError {
color: var(--danger-fg);
margin-top: var(--margin-sm);
}
.updateGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(3, 1fr);
}
.updateGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.updateLink {
color: var(--main-fg);
display: inline-block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.updateMessage {
margin-top: var(--margin-sm);
}
.updateProgressCard {
margin-top: var(--margin-base);
}
.updateProgressDetail {
font-size: var(--font-size-sm);
margin-top: var(--margin-sm);
}
.updateProgressDetail.error {
color: var(--danger-fg);
}
.updateProgressFill {
background: linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%);
border-radius: var(--border-radius-sm);
height: 100%;
transition: width var(--transition-base);
}
.updateProgressFill.error {
background: linear-gradient(90deg, #b43a3a 0%, #de5656 100%);
}
.updateProgressHeader {
display: flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
justify-content: space-between;
margin-bottom: var(--margin-xs);
}
.updateProgressTrack {
background: var(--track-color);
border-radius: var(--border-radius-sm);
height: 12px;
overflow: hidden;
}
.updateProgressTrack.error {
border: 1px solid var(--danger-bg);
}
.updateToggleRow {
align-items: center;
display: flex;
gap: var(--gap-sm);
justify-content: space-between;
margin-top: var(--margin-base);
}
.updateWarning {
margin-top: var(--margin-base);
}
.updateHint {
font-size: var(--font-size-sm);
margin-top: var(--margin-sm);
}
.updateRebootNotice {
font-weight: var(--font-weight-bold);
}
.updateFooter {
border-top: 1px solid var(--track-color);
margin-top: var(--margin-base);
padding-top: var(--margin-sm);
}
.updateBranchSection {
margin-top: var(--margin-base);
}
.updateAdvancedNote {
font-size: var(--font-size-sm);
margin-top: var(--margin-sm);
}
.updateBranchHeader {
align-items: center;
display: flex;
font-size: var(--font-size-sm);
justify-content: space-between;
}
.updateBranchRow {
align-items: center;
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-sm);
}
.updateSelect {
background-color: var(--main-bg);
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
color: var(--text-color);
flex: 1;
font-size: var(--font-size-sm);
min-width: 0;
padding: var(--padding-sm);
}
.updateSelect:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.updateToggleRow input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.updateActions {
flex-direction: column;
}
.updateBranchRow {
flex-direction: column;
align-items: stretch;
}
.updateCard {
max-width: 100%;
}
.updateGrid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
}
@@ -0,0 +1,603 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
const state = reactive({
loading: true,
error: "",
status: null,
checkedForUpdates: false,
checkBusy: false,
updateBusy: false,
toggleBusy: false,
showAdvancedOptions: false,
branches: [],
selectedBranch: "",
hasManualBranchSelection: false,
branchesError: "",
branchesBusy: false,
switchBusy: false,
})
let initialized = false
let pollHandle = null
let reconnectPending = false
let rebootNoticeShown = false
const POLL_INTERVAL_MS = 1000
const ADVANCED_OPTIONS_KEY = "softwareShowAdvancedOptions"
function shortHash(value) {
const text = String(value || "").trim()
return text ? text.slice(0, 10) : "Unknown"
}
function toPercent(value) {
const n = Number(value)
if (!Number.isFinite(n)) return 0
return Math.max(0, Math.min(100, n))
}
function findBranchInList(branches, target) {
const wanted = String(target || "").trim()
if (!wanted || !Array.isArray(branches) || branches.length === 0) return ""
if (branches.includes(wanted)) return wanted
const wantedLower = wanted.toLowerCase()
const caseInsensitive = branches.find((branch) => String(branch || "").toLowerCase() === wantedLower)
return caseInsensitive || ""
}
function isSelectedBranchDifferent() {
const currentBranch = String(state.status?.branch || "").trim()
const selectedBranch = String(state.selectedBranch || "").trim()
return !!currentBranch && !!selectedBranch && currentBranch !== selectedBranch
}
function normalizeGithubRemote(remoteValue, commitsUrlValue = "") {
let remote = String(remoteValue || "").trim()
if (!remote) {
const commitsUrl = String(commitsUrlValue || "").trim()
const marker = "/commits/"
const markerIndex = commitsUrl.indexOf(marker)
if (commitsUrl.startsWith("https://github.com/") && markerIndex > 0) {
remote = commitsUrl.slice(0, markerIndex)
} else {
return ""
}
}
if (remote.startsWith("git@github.com:")) {
remote = `https://github.com/${remote.split(":", 2)[1] || ""}`
} else if (remote.startsWith("ssh://git@github.com/")) {
remote = `https://github.com/${remote.split("ssh://git@github.com/", 2)[1] || ""}`
} else if (remote.startsWith("http://github.com/")) {
remote = `https://github.com/${remote.split("http://github.com/", 2)[1] || ""}`
}
if (!remote.startsWith("https://github.com/")) return ""
remote = remote.replace(/\/+$/, "")
if (remote.endsWith(".git")) remote = remote.slice(0, -4)
return remote
}
function currentOrSelectedBranchForCommits() {
const selected = String(state.selectedBranch || "").trim()
if (selected) return selected
return String(state.status?.branch || "").trim()
}
function activeCommitsUrl() {
const branch = currentOrSelectedBranchForCommits()
if (!branch) return ""
const remote = normalizeGithubRemote(state.status?.originRemote, state.status?.commitsUrl)
if (!remote) return ""
return `${remote}/commits/${encodeURIComponent(branch)}/`
}
function activeCommitsLabel() {
const branch = currentOrSelectedBranchForCommits()
return branch
? `View latest commit for the "${branch}" branch`
: "View latest commit for this branch"
}
function shouldShowPrimaryUpdateAction() {
if (state.status?.running) return true
if (isSelectedBranchDifferent()) return true
return !!state.checkedForUpdates && !!state.status?.updateAvailable
}
function shouldContinuePolling() {
return !!state.status?.running || state.status?.stage === "rebooting" || reconnectPending
}
function shouldShowRebootNotice() {
if (state.status?.stage === "rebooting") return true
if (reconnectPending) return true
const message = String(state.status?.message || "").toLowerCase()
return message.includes("reboot")
}
function isHtmlLike(text) {
const value = String(text || "").trim()
return value.startsWith("<!DOCTYPE") || value.startsWith("<html") || value.startsWith("<")
}
async function readJsonPayload(response) {
const bodyText = await response.text()
if (!bodyText) return {}
try {
return JSON.parse(bodyText)
} catch (parseError) {
const error = new Error("Invalid JSON response")
error.cause = parseError
error.bodyText = bodyText
throw error
}
}
function setRebootingUiState(detail = "Reboot in progress...") {
state.status = {
...(state.status || {}),
running: false,
stage: "rebooting",
progressStep: 5,
progressTotalSteps: state.status?.progressTotalSteps || 5,
progressStepPercent: 100,
progressPercent: 100,
progressLabel: "Rebooting device",
progressDetail: detail,
message: "Update complete. Device is rebooting. Please wait...",
}
state.error = ""
}
function isRebootRelatedConnectionError(error) {
const message = String(error?.message || "")
return /Invalid JSON response|Unexpected token '<'|Failed to fetch|NetworkError|Load failed/i.test(message)
}
function stopPolling() {
if (!pollHandle) return
clearTimeout(pollHandle)
pollHandle = null
}
function ensurePolling() {
if (pollHandle) return
const poll = async () => {
await fetchStatus(false)
if (shouldContinuePolling()) {
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
} else {
pollHandle = null
}
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
async function fetchStatus(showToast) {
if (showToast) state.checkBusy = true
try {
const response = await fetch("/api/update/fast/status")
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load update status")
}
state.status = payload
state.error = ""
if (reconnectPending && !payload.running && payload.stage !== "rebooting") {
reconnectPending = false
rebootNoticeShown = false
showSnackbar("Connection reestablished.")
}
if (payload.stage === "rebooting" && !rebootNoticeShown) {
rebootNoticeShown = true
showSnackbar("Device rebooting, please wait for reconnection...")
}
if (!state.selectedBranch && payload.branch) {
state.selectedBranch = payload.branch
}
if (shouldContinuePolling()) {
ensurePolling()
} else {
stopPolling()
}
if (showToast) {
state.checkedForUpdates = true
showSnackbar(payload.updateAvailable ? "Update available." : "No update available.")
}
} catch (error) {
const isRebootTransitionError = !showToast
&& (state.status?.running || state.status?.stage === "rebooting" || reconnectPending)
&& (isHtmlLike(error?.bodyText) || isRebootRelatedConnectionError(error))
if (isRebootTransitionError) {
reconnectPending = true
setRebootingUiState("Device rebooting, please wait for reconnection...")
if (!rebootNoticeShown) {
rebootNoticeShown = true
showSnackbar("Device rebooting, please wait for reconnection...")
}
ensurePolling()
return
}
const message = error?.message || String(error)
state.error = message
if (showToast) {
showSnackbar(message, "error")
}
} finally {
if (showToast) state.checkBusy = false
}
}
async function fetchBranches(showToast = false) {
state.branchesBusy = true
try {
const response = await fetch("/api/update/branches")
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load branches")
}
const branches = Array.isArray(payload.branches) ? payload.branches : []
const currentBranchRaw = String(payload.currentBranch || state.status?.branch || "").trim()
const matchedCurrentBranch = findBranchInList(branches, currentBranchRaw)
state.branches = branches
state.branchesError = payload.remoteError || ""
if (branches.length === 0) {
state.selectedBranch = currentBranchRaw
state.hasManualBranchSelection = false
} else if (matchedCurrentBranch && !state.hasManualBranchSelection) {
// Default to the branch currently installed unless the user picked one explicitly.
state.selectedBranch = matchedCurrentBranch
} else if (!state.selectedBranch || !branches.includes(state.selectedBranch)) {
state.selectedBranch = matchedCurrentBranch || branches[0]
state.hasManualBranchSelection = false
}
if (showToast) {
showSnackbar(branches.length ? `Loaded ${branches.length} branches.` : "No branches found.")
}
} catch (error) {
const message = error?.message || "Failed to load branches"
state.branchesError = message
if (showToast) {
showSnackbar(message, "error")
}
} finally {
state.branchesBusy = false
}
}
function setAdvancedOptions(enabled) {
const next = !!enabled
state.showAdvancedOptions = next
if (!next) {
const currentBranch = String(state.status?.branch || "").trim()
if (currentBranch) {
state.selectedBranch = currentBranch
}
state.hasManualBranchSelection = false
}
try {
localStorage.setItem(ADVANCED_OPTIONS_KEY, next ? "1" : "0")
} catch (error) {
console.warn("Failed to persist advanced options preference", error)
}
if (next && state.branches.length === 0 && !state.branchesBusy) {
fetchBranches(false)
}
}
async function setAutomaticUpdates(enabled) {
if (state.toggleBusy) return
if (state.status?.isOnroad) {
showSnackbar("Actions are blocked while onroad.", "error")
return
}
state.toggleBusy = true
try {
const response = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "AutomaticUpdates", value: !!enabled }),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to update Automatic Updates")
}
state.status = { ...(state.status || {}), automaticUpdates: !!enabled }
showSnackbar(payload.message || "Automatic updates updated.")
} catch (error) {
showSnackbar(error?.message || "Failed to update Automatic Updates", "error")
await fetchStatus(false)
} finally {
state.toggleBusy = false
}
}
async function runFastUpdate() {
if (state.updateBusy) return
if (state.status?.running) {
showSnackbar("Fast update is already running.")
return
}
if (state.status?.isOnroad) {
showSnackbar("Actions are blocked while onroad.", "error")
return
}
const confirmed = window.confirm(
"Fast update warning:\n\n" +
"- This update method skips backup creation.\n" +
"- Your device will reboot when the update is done.\n\n" +
"Continue with fast update?"
)
if (!confirmed) return
state.updateBusy = true
try {
reconnectPending = false
rebootNoticeShown = false
const response = await fetch("/api/update/fast", { method: "POST" })
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to start fast update")
}
state.status = {
...(state.status || {}),
running: true,
stage: "starting",
progressStep: 1,
progressTotalSteps: state.status?.progressTotalSteps || 5,
progressStepPercent: 0,
progressPercent: 0,
progressLabel: "Preparing update",
progressDetail: "Initializing update process...",
message: payload.message || "Fast update started. Device will reboot when complete.",
lastError: "",
}
state.error = ""
showSnackbar(payload.message || "Fast update started.")
await fetchStatus(false)
ensurePolling()
} catch (error) {
showSnackbar(error?.message || "Failed to start fast update", "error")
} finally {
state.updateBusy = false
}
}
async function runBranchSwitch() {
if (state.switchBusy) return
if (state.status?.running) {
showSnackbar("An update action is already running.")
return
}
if (state.status?.isOnroad) {
showSnackbar("Actions are blocked while onroad.", "error")
return
}
const targetBranch = String(state.selectedBranch || "").trim()
if (!targetBranch) {
showSnackbar("Select a target branch first.", "error")
return
}
const currentBranch = String(state.status?.branch || "").trim()
const actionLabel = currentBranch && currentBranch === targetBranch ? "update" : "switch and update"
const confirmed = window.confirm(
`This will ${actionLabel} to the '${targetBranch}' branch.\n\n` +
"- This update method skips backup creation.\n" +
"- Your device will reboot when the update is done.\n\n" +
"Continue?"
)
if (!confirmed) return
state.switchBusy = true
try {
reconnectPending = false
rebootNoticeShown = false
const response = await fetch("/api/update/branch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ branch: targetBranch }),
})
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to start branch switch")
}
state.status = {
...(state.status || {}),
running: true,
stage: "starting",
progressStep: 1,
progressTotalSteps: state.status?.progressTotalSteps || 5,
progressStepPercent: 0,
progressPercent: 0,
progressLabel: "Preparing branch switch",
progressDetail: "Initializing branch switch...",
message: payload.message || `Branch switch started for '${targetBranch}'. Device will reboot when complete.`,
lastError: "",
}
state.error = ""
showSnackbar(payload.message || "Branch switch started.")
await fetchStatus(false)
ensurePolling()
} catch (error) {
showSnackbar(error?.message || "Failed to start branch switch", "error")
} finally {
state.switchBusy = false
}
}
function initialize() {
if (initialized) return
initialized = true
try {
state.showAdvancedOptions = localStorage.getItem(ADVANCED_OPTIONS_KEY) === "1"
} catch (error) {
state.showAdvancedOptions = false
}
const initTasks = [fetchStatus(false)]
if (state.showAdvancedOptions) {
initTasks.push(fetchBranches(false))
}
Promise.all(initTasks).finally(() => {
state.loading = false
})
}
export function UpdateManager() {
initialize()
return html`
<div class="updateManager">
<h2>Software</h2>
${() => state.loading ? html`<div class="updateCard">Loading update status...</div>` : ""}
${() => !state.loading ? html`
<div class="updateCard">
<div class="updateGrid">
<p><strong>Current Branch:</strong> ${state.status?.branch || "Unknown"}</p>
<p><strong>Installed Commit:</strong> ${shortHash(state.status?.localCommit)}</p>
<p><strong>Latest Commit:</strong> ${shortHash(state.status?.remoteCommit)}</p>
<p><strong>Update Available:</strong> ${state.status?.updateAvailable ? "Yes" : "No"}</p>
<p><strong>Status:</strong> ${state.status?.stage || "idle"}</p>
<p><strong>Onroad:</strong> ${state.status?.isOnroad ? "Yes" : "No"}</p>
</div>
<div class="updateProgressCard">
<div class="updateProgressHeader">
<span>
Step ${state.status?.progressStep || 0}/${state.status?.progressTotalSteps || 5}:
${state.status?.progressLabel || "Idle"}
</span>
<span>${Math.round(toPercent(state.status?.progressPercent))}%</span>
</div>
<div class="updateProgressTrack ${state.status?.stage === "error" ? "error" : ""}">
<div class="updateProgressFill ${state.status?.stage === "error" ? "error" : ""}" style="width: ${toPercent(state.status?.progressPercent)}%;"></div>
</div>
${() => state.status?.progressDetail ? html`<p class="updateProgressDetail ${state.status?.stage === "error" ? "error" : ""}">${state.status.progressDetail}</p>` : ""}
</div>
${() => state.status?.isOnroad ? html`<p class="updateWarning"><strong>Onroad: actions disabled</strong></p>` : ""}
<label class="updateToggleRow">
<span>Automatically install updates</span>
<input
type="checkbox"
class="ds-toggle"
?checked="${!!state.status?.automaticUpdates}"
?disabled="${!!state.status?.isOnroad || state.toggleBusy || !!state.status?.running}"
@change="${(event) => setAutomaticUpdates(!!event.target.checked)}"
/>
</label>
<label class="updateToggleRow updateAdvancedToggle">
<span>Show advanced options</span>
<input
type="checkbox"
class="ds-toggle"
?checked="${() => state.showAdvancedOptions}"
@change="${(event) => setAdvancedOptions(!!event.target.checked)}"
/>
</label>
${() => state.showAdvancedOptions ? html`
<div class="updateBranchSection">
<div class="updateBranchHeader">
<strong>Branch switching</strong>
${state.branchesBusy ? html`<span>Loading...</span>` : ""}
</div>
<p class="updateAdvancedNote">
For advanced users only. Switching to test/dev branches can introduce instability.
</p>
<div class="updateBranchRow">
<select
class="updateSelect"
?disabled="${!!state.status?.isOnroad || !!state.status?.running || state.switchBusy || state.branchesBusy || state.branches.length === 0}"
@change="${(event) => {
state.selectedBranch = String(event.target.value || "")
state.hasManualBranchSelection = true
}}">
${() => state.branches.length
? state.branches.map((branch) => html`<option value="${branch}" ${branch === state.selectedBranch ? "selected" : ""}>${branch}${branch === state.status?.branch ? " (current)" : ""}</option>`)
: html`<option value="">No branches found</option>`
}
</select>
<button
class="updateButton"
?disabled="${state.branchesBusy || !!state.status?.running}"
@click="${() => fetchBranches(true)}">
${state.branchesBusy ? "Refreshing..." : "Refresh Branches"}
</button>
</div>
${() => state.branchesError ? html`<p class="updateError"><strong>Branch List:</strong> ${state.branchesError}</p>` : ""}
</div>
` : ""}
${() => state.status?.message && state.status?.stage !== "rebooting" ? html`<p class="updateMessage">${state.status.message}</p>` : ""}
${() => state.status?.remoteError ? html`<p class="updateError"><strong>Remote Check:</strong> ${state.status.remoteError}</p>` : ""}
${() => state.status?.lastError ? html`<p class="updateError"><strong>Last Error:</strong> ${state.status.lastError}</p>` : ""}
${() => state.error ? html`<p class="updateError"><strong>Error:</strong> ${state.error}</p>` : ""}
<div class="updateActions">
${() => !isSelectedBranchDifferent() ? html`
<button class="updateButton" @click="${() => fetchStatus(true)}">
${state.checkBusy ? "Checking..." : "Check for Updates"}
</button>
` : ""}
${() => shouldShowPrimaryUpdateAction() ? html`
<button
class="updateButton danger"
?disabled="${!!state.status?.isOnroad || !!state.status?.running || (isSelectedBranchDifferent() ? state.switchBusy : state.updateBusy)}"
@click="${() => isSelectedBranchDifferent() ? runBranchSwitch() : runFastUpdate()}">
${state.status?.running
? "Update Running..."
: (isSelectedBranchDifferent()
? (state.switchBusy ? "Starting..." : "Switch + Update")
: (state.updateBusy ? "Starting..." : "Fast Update (No Backup)"))}
</button>
` : ""}
</div>
${() => !state.status?.running && !shouldShowPrimaryUpdateAction()
? html`<p class="updateHint">Run <strong>Check for Updates</strong> first, or select a different branch in advanced options.</p>`
: ""}
${() => shouldShowRebootNotice()
? html`<p class="updateHint updateRebootNotice">Device rebooting, please wait for reconnection...</p>`
: ""}
${() => activeCommitsUrl() ? html`
<div class="updateFooter">
<a class="updateLink" href="${activeCommitsUrl()}" target="_blank" rel="noopener noreferrer">
${activeCommitsLabel()}
</a>
</div>
` : ""}
</div>
` : ""}
</div>
`
}
@@ -32,6 +32,7 @@
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
+660
View File
@@ -14,11 +14,14 @@ import os
import re
import requests
import secrets
import selectors
import shutil
import signal
import subprocess
import threading
import time
import traceback
from urllib.parse import quote
from cereal import car, messaging
from opendbc.can.parser import CANParser
@@ -134,9 +137,558 @@ _FINGERPRINT_VALID_NAME_RE = re.compile(r'^[A-Za-z0-9 \u0160.()\-]+$')
_openpilot_root_cache = None
_fingerprint_catalog_cache = None
_fast_update_lock = threading.Lock()
_FAST_UPDATE_TOTAL_STEPS = 5
_FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S = 5.0
_FAST_UPDATE_REBOOT_NOTICE_SECONDS = 6.0
_FAST_UPDATE_FETCH_TIMEOUT_S = 60
_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S = 60
_GIT_PROGRESS_PERCENT_RE = re.compile(r'([A-Za-z][A-Za-z /_-]+):\s*([0-9]{1,3})%')
_GIT_SUBMODULE_SECTION_RE = re.compile(r'^\s*\[submodule\s+"[^"]+"\]\s*$', re.MULTILINE)
_fast_update_state = {
"running": False,
"stage": "idle",
"message": "",
"lastError": "",
"lastBranch": "",
"lastMode": "",
"startedAt": 0.0,
"finishedAt": 0.0,
"progressStep": 0,
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
"progressStepPercent": 0.0,
"progressPercent": 0.0,
"progressLabel": "Idle",
"progressDetail": "",
}
def _normalize_fingerprint_make_key(make_value):
return str(make_value or "").strip().lower()
def _set_fast_update_state(**kwargs):
with _fast_update_lock:
_fast_update_state.update(kwargs)
def _get_fast_update_state():
with _fast_update_lock:
return dict(_fast_update_state)
def _set_fast_update_progress(step, label, step_percent=0.0, detail=""):
safe_step = max(1, min(_FAST_UPDATE_TOTAL_STEPS, int(step)))
safe_step_percent = float(max(0.0, min(100.0, step_percent)))
overall_percent = (((safe_step - 1) + (safe_step_percent / 100.0)) / _FAST_UPDATE_TOTAL_STEPS) * 100.0
_set_fast_update_state(
progressStep=safe_step,
progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS,
progressStepPercent=round(safe_step_percent, 1),
progressPercent=round(overall_percent, 1),
progressLabel=label,
progressDetail=detail,
)
def _parse_git_progress_line(raw_line):
text = str(raw_line or "").replace("\x1b", "").strip()
while text.startswith("remote:"):
text = text[len("remote:"):].strip()
if not text:
return None, "", ""
match = _GIT_PROGRESS_PERCENT_RE.search(text)
if not match:
return None, text, ""
try:
percent = float(match.group(2))
except Exception:
percent = None
phase = str(match.group(1) or "").strip().lower()
return percent, text, phase
def _normalize_git_phase_percent(phase, percent):
safe_percent = max(0.0, min(100.0, float(percent)))
phase_text = str(phase or "").strip().lower()
# Git progress lines are per-phase and can hit 100% multiple times before the
# command actually exits. Map known phases to a monotonic 0..99% envelope.
if "counting objects" in phase_text:
return min(20.0, safe_percent * 0.20)
if "compressing objects" in phase_text:
return min(45.0, 20.0 + (safe_percent * 0.25))
if "receiving objects" in phase_text:
return min(85.0, 45.0 + (safe_percent * 0.40))
if "resolving deltas" in phase_text:
return min(99.0, 85.0 + (safe_percent * 0.14))
# Unknown phase: keep below 100 until the process exits.
return min(99.0, safe_percent)
def _git_command_env():
env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0"
env["GIT_ASKPASS"] = "/bin/false"
env["SSH_ASKPASS"] = "/bin/false"
env["GCM_INTERACTIVE"] = "Never"
if not env.get("GIT_SSH_COMMAND"):
env["GIT_SSH_COMMAND"] = "ssh -oBatchMode=yes"
return env
def _build_shallow_fetch_args(branch):
return [
"-c", "gc.auto=0",
"-c", "maintenance.auto=false",
"fetch",
"--progress",
"--depth=1",
"--no-recurse-submodules",
"origin",
branch,
]
def _run_git_with_progress(repo_path, args, timeout, step, label):
cmd = ["git", *args]
process = subprocess.Popen(
cmd,
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=_git_command_env(),
)
if process.stdout is None:
raise RuntimeError("Failed to open git output stream")
fd = process.stdout.fileno()
os.set_blocking(fd, False)
selector = selectors.DefaultSelector()
selector.register(fd, selectors.EVENT_READ)
started_at = time.monotonic()
last_activity_at = started_at
last_emit_at = 0.0
last_percent = None
last_detail = ""
output_tail = []
buffer = ""
def consume_text(text):
nonlocal buffer
for char in text:
if char in ("\r", "\n"):
if buffer:
handle_line(buffer)
buffer = ""
else:
buffer += char
def append_tail(text):
if not text:
return
output_tail.append(text)
if len(output_tail) > 180:
del output_tail[:-180]
def handle_line(text):
nonlocal last_activity_at, last_emit_at, last_percent, last_detail
percent, detail, phase = _parse_git_progress_line(text)
append_tail(detail or text)
now = time.monotonic()
last_activity_at = now
should_emit = False
if percent is not None:
safe_percent = _normalize_git_phase_percent(phase, percent)
if safe_percent in (0.0, 99.0):
should_emit = True
elif now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S:
should_emit = last_percent is None or abs(safe_percent - last_percent) >= 1.0
if should_emit:
_set_fast_update_progress(step, label, safe_percent, detail or label)
last_emit_at = now
last_percent = safe_percent
last_detail = detail or label
else:
if last_percent is None:
last_percent = safe_percent
else:
if detail and now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S and detail != last_detail:
fallback_percent = last_percent if last_percent is not None else 0.0
_set_fast_update_progress(step, label, fallback_percent, detail)
last_emit_at = now
last_detail = detail
try:
while True:
now = time.monotonic()
if timeout and (now - last_activity_at) > timeout:
try:
process.kill()
except Exception:
pass
tail = output_tail[-1] if output_tail else ""
suffix = f" (last output: {tail})" if tail else ""
raise TimeoutError(f"git {' '.join(args)} stalled for {int(timeout)}s without output{suffix}")
events = selector.select(timeout=0.5)
if not events:
if process.poll() is not None:
try:
trailing = os.read(fd, 4096)
except BlockingIOError:
trailing = b""
if trailing:
last_activity_at = time.monotonic()
consume_text(trailing.decode("utf-8", errors="replace"))
continue
break
# Heartbeat: if git is quiet (no progress lines), still surface activity.
now = time.monotonic()
if now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S:
if timeout:
inferred_percent = min(95.0, max(0.0, ((now - started_at) / timeout) * 95.0))
else:
inferred_percent = min(95.0, (last_percent or 0.0) + 1.0)
if last_percent is None or inferred_percent > last_percent:
last_percent = inferred_percent
heartbeat_detail = last_detail or f"{label}..."
_set_fast_update_progress(step, label, last_percent or 0.0, heartbeat_detail)
last_emit_at = now
continue
reached_eof = False
for _, _ in events:
try:
chunk = os.read(fd, 4096)
except BlockingIOError:
chunk = b""
if not chunk:
# Selector can keep reporting readability on EOF; exit once process ended.
if process.poll() is not None:
reached_eof = True
break
continue
last_activity_at = time.monotonic()
consume_text(chunk.decode("utf-8", errors="replace"))
if reached_eof:
break
if buffer:
handle_line(buffer)
return_code = process.wait(timeout=2)
finally:
try:
selector.unregister(fd)
except Exception:
pass
selector.close()
try:
process.stdout.close()
except Exception:
pass
if return_code == 0:
_set_fast_update_progress(step, label, 100.0, last_detail or "Done")
return return_code, "\n".join(output_tail[-40:])
def _run_git(repo_path, args, timeout=30):
return subprocess.run(
["git", *args],
cwd=repo_path,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=_git_command_env(),
)
def _git_stdout(repo_path, args, timeout=15):
result = _run_git(repo_path, args, timeout=timeout)
if result.returncode != 0:
stderr = (result.stderr or "").strip() or (result.stdout or "").strip() or "git command failed"
raise RuntimeError(stderr)
return (result.stdout or "").strip()
def _is_valid_git_branch_name(repo_path, branch_name):
branch = str(branch_name or "").strip()
if not branch or branch.startswith("-") or "\x00" in branch:
return False
result = _run_git(repo_path, ["check-ref-format", "--branch", branch], timeout=10)
return result.returncode == 0
def _list_origin_branches(repo_path, include_remote=True):
branches = set()
remote_error = ""
if include_remote:
try:
remote_heads = _git_stdout(repo_path, ["ls-remote", "--heads", "origin"], timeout=25)
for line in remote_heads.splitlines():
parts = line.split()
if len(parts) < 2:
continue
ref = parts[1].strip()
if not ref.startswith("refs/heads/"):
continue
branch = ref[len("refs/heads/"):].strip()
if branch:
branches.add(branch)
except Exception as exception:
remote_error = str(exception)
if not branches:
try:
local_refs = _git_stdout(
repo_path,
["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"],
timeout=15,
)
for line in local_refs.splitlines():
ref = line.strip()
if not ref or ref in ("origin/HEAD",):
continue
if ref.startswith("origin/"):
ref = ref[len("origin/"):]
if ref.endswith("/HEAD"):
continue
if ref:
branches.add(ref)
except Exception as exception:
if not remote_error:
remote_error = str(exception)
return sorted(branches, key=lambda branch: branch.lower()), remote_error
def _repo_has_submodule_entries(repo_path):
gitmodules_path = Path(repo_path) / ".gitmodules"
if not gitmodules_path.is_file():
return False
try:
content = gitmodules_path.read_text(encoding="utf-8", errors="replace")
except Exception:
# If we cannot inspect .gitmodules, stay conservative and try syncing.
return True
return bool(_GIT_SUBMODULE_SECTION_RE.search(content))
def _run_submodule_update_if_needed(repo_path, step=4):
if not _repo_has_submodule_entries(repo_path):
_set_fast_update_progress(step, "Updating submodules", 100.0, "No submodules configured.")
return
_set_fast_update_progress(step, "Updating submodules", 0.0, "Syncing submodules...")
submodule_rc, submodule_output = _run_git_with_progress(
repo_path,
["submodule", "update", "--init", "--recursive", "--depth=1", "--progress"],
timeout=240,
step=step,
label="Updating submodules",
)
if submodule_rc != 0:
raise RuntimeError(submodule_output.strip() or "git submodule update failed")
def _finish_update_and_reboot(message):
_set_fast_update_progress(5, "Rebooting device", 100.0, "Update complete. Please wait for device to reboot.")
_set_fast_update_state(
running=False,
stage="rebooting",
message=message,
finishedAt=time.time(),
)
# Keep the service online briefly so the UI can fetch and render the reboot notice.
time.sleep(_FAST_UPDATE_REBOOT_NOTICE_SECONDS)
HARDWARE.reboot()
def _set_fast_update_error_state(message, exception):
error_text = str(exception).strip() or "Unknown error"
_set_fast_update_state(
running=False,
stage="error",
message=message,
lastError=error_text,
finishedAt=time.time(),
progressStep=0,
progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS,
progressStepPercent=0.0,
progressPercent=0.0,
progressLabel="Failed",
progressDetail="Update failed. See Last Error below.",
)
def _collect_fast_update_info(include_remote=True):
repo_path = str(_get_openpilot_root())
branch = ""
local_commit = ""
remote_commit = ""
update_available = False
remote_error = ""
origin_remote = ""
commits_url = ""
try:
branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
local_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
try:
origin_remote = _git_stdout(repo_path, ["config", "--get", "remote.origin.url"])
except Exception:
origin_remote = ""
except Exception as exception:
return {
"repoPath": repo_path,
"branch": branch,
"localCommit": local_commit,
"remoteCommit": remote_commit,
"updateAvailable": False,
"remoteError": str(exception),
"originRemote": origin_remote,
"commitsUrl": commits_url,
}
if origin_remote:
remote = origin_remote.strip()
if remote.startswith("git@github.com:"):
remote = "https://github.com/" + remote.split(":", 1)[1]
elif remote.startswith("ssh://git@github.com/"):
remote = "https://github.com/" + remote.split("ssh://git@github.com/", 1)[1]
elif remote.startswith("http://github.com/"):
remote = "https://github.com/" + remote.split("http://github.com/", 1)[1]
if remote.startswith("https://github.com/"):
remote = remote.rstrip("/")
if remote.endswith(".git"):
remote = remote[:-4]
if branch:
commits_url = f"{remote}/commits/{quote(branch, safe='')}/"
if branch and include_remote:
try:
remote_raw = _git_stdout(repo_path, ["ls-remote", "--heads", "origin", branch], timeout=20)
if remote_raw:
remote_commit = remote_raw.split()[0]
update_available = bool(local_commit and remote_commit and local_commit != remote_commit)
except Exception as exception:
remote_error = str(exception)
return {
"repoPath": repo_path,
"branch": branch,
"localCommit": local_commit,
"remoteCommit": remote_commit,
"updateAvailable": update_available,
"remoteError": remote_error,
"originRemote": origin_remote,
"commitsUrl": commits_url,
}
def _fast_update_worker():
started_at = time.time()
repo_path = str(_get_openpilot_root())
try:
_set_fast_update_progress(1, "Preparing update", 10.0, "Resolving active branch...")
branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
_set_fast_update_progress(1, "Preparing update", 100.0, f"Branch: {branch}")
_set_fast_update_state(
running=True,
stage="updating",
message=f"Applying shallow update on '{branch}'...",
lastError="",
lastBranch=branch,
lastMode="fetch-reset",
startedAt=started_at,
finishedAt=0.0,
)
_set_fast_update_progress(2, "Fetching branch snapshot", 0.0, "Fetching latest shallow commit...")
fetch_rc, fetch_output = _run_git_with_progress(
repo_path,
_build_shallow_fetch_args(branch),
timeout=_FAST_UPDATE_FETCH_TIMEOUT_S,
step=2,
label="Fetching branch snapshot",
)
if fetch_rc != 0:
raise RuntimeError(fetch_output.strip() or "git fetch failed")
_set_fast_update_progress(3, "Applying fetched commit", 20.0, "Resetting repository to fetched head...")
reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120)
if reset.returncode != 0:
raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip())
_set_fast_update_progress(3, "Applying fetched commit", 100.0, "Repository reset complete.")
_run_submodule_update_if_needed(repo_path, step=4)
_finish_update_and_reboot(
"Update successful. Device is rebooting now. Please wait for reconnection."
)
except Exception as exception:
_set_fast_update_error_state("Fast update failed.", exception)
def _branch_switch_worker(target_branch):
started_at = time.time()
repo_path = str(_get_openpilot_root())
try:
_set_fast_update_progress(1, "Preparing branch switch", 10.0, f"Target: {target_branch}")
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
_set_fast_update_progress(1, "Preparing branch switch", 100.0, f"{current_branch} -> {target_branch}")
_set_fast_update_state(
running=True,
stage="switching",
message=f"Switching to '{target_branch}' with shallow fetch...",
lastError="",
lastBranch=target_branch,
lastMode="branch-switch",
startedAt=started_at,
finishedAt=0.0,
)
_set_fast_update_progress(2, "Fetching branch snapshot", 0.0, f"Fetching '{target_branch}' from origin...")
fetch_rc, fetch_output = _run_git_with_progress(
repo_path,
_build_shallow_fetch_args(target_branch),
timeout=_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S,
step=2,
label="Fetching branch snapshot",
)
if fetch_rc != 0:
raise RuntimeError(fetch_output.strip() or f"git fetch failed for '{target_branch}'")
_set_fast_update_progress(3, "Switching branch", 20.0, f"Checking out '{target_branch}'...")
checkout = _run_git(repo_path, ["checkout", "--force", "-B", target_branch, "FETCH_HEAD"], timeout=120)
if checkout.returncode != 0:
raise RuntimeError((checkout.stderr or checkout.stdout or "git checkout failed").strip())
reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120)
if reset.returncode != 0:
raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip())
_run_git(repo_path, ["branch", "--set-upstream-to", f"origin/{target_branch}", target_branch], timeout=30)
_set_fast_update_progress(3, "Switching branch", 100.0, f"Now on '{target_branch}'.")
_run_submodule_update_if_needed(repo_path, step=4)
_finish_update_and_reboot(
f"Switched to '{target_branch}'. Device is rebooting now. Please wait for reconnection."
)
except Exception as exception:
_set_fast_update_error_state("Fast branch switch failed.", exception)
def _get_openpilot_root():
global _openpilot_root_cache
if _openpilot_root_cache is not None:
@@ -639,6 +1191,9 @@ def setup(app):
name = friendly_names.get(key, key)
return jsonify({"error": f"Cannot change {name} while the car is driving. A reboot is required."}), 403
if key == "AutomaticUpdates" and params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot change Automatic Updates while driving."}), 403
if key == "CarMake":
catalog = _get_fingerprint_catalog()
normalized_make = _normalize_fingerprint_make_key(str_val)
@@ -1381,6 +1936,111 @@ def setup(app):
},
}
@app.route("/api/update/fast/status", methods=["GET"])
def get_fast_update_status():
state_data = _get_fast_update_state()
git_data = _collect_fast_update_info(include_remote=not state_data.get("running", False))
return jsonify({
**state_data,
**git_data,
"isOnroad": params.get_bool("IsOnroad"),
"automaticUpdates": params.get_bool("AutomaticUpdates"),
"warning": "Fast update skips backup creation and finalization safeguards.",
}), 200
@app.route("/api/update/branches", methods=["GET"])
def get_update_branches():
state_data = _get_fast_update_state()
repo_path = str(_get_openpilot_root())
try:
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
except Exception as exception:
return jsonify({"error": str(exception)}), 500
branches, remote_error = _list_origin_branches(repo_path, include_remote=not state_data.get("running", False))
if current_branch and current_branch not in branches:
branches = sorted([*branches, current_branch], key=lambda branch: branch.lower())
return jsonify({
"currentBranch": current_branch,
"branches": branches,
"remoteError": remote_error,
"isOnroad": params.get_bool("IsOnroad"),
"running": state_data.get("running", False),
}), 200
@app.route("/api/update/fast", methods=["POST"])
def run_fast_update():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot run a fast update while driving."}), 409
with _fast_update_lock:
if _fast_update_state.get("running"):
return jsonify({"error": "Fast update already in progress."}), 409
_fast_update_state.update({
"running": True,
"stage": "starting",
"message": "Starting fast update...",
"lastError": "",
"startedAt": time.time(),
"finishedAt": 0.0,
"progressStep": 1,
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
"progressStepPercent": 0.0,
"progressPercent": 0.0,
"progressLabel": "Preparing update",
"progressDetail": "Initializing update process...",
})
threading.Thread(target=_fast_update_worker, daemon=True).start()
return jsonify({
"message": "Fast update started. Device will reboot when complete.",
"warning": "Fast update skips backup creation and finalization safeguards.",
}), 202
@app.route("/api/update/branch", methods=["POST"])
def run_branch_switch():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot switch branches while driving."}), 409
request_data = request.get_json() or {}
target_branch = str(request_data.get("branch") or "").strip()
if not target_branch:
return jsonify({"error": "Missing 'branch' in request body."}), 400
repo_path = str(_get_openpilot_root())
if not _is_valid_git_branch_name(repo_path, target_branch):
return jsonify({"error": "Invalid branch name."}), 400
with _fast_update_lock:
if _fast_update_state.get("running"):
return jsonify({"error": "Another update action is already in progress."}), 409
_fast_update_state.update({
"running": True,
"stage": "starting",
"message": f"Starting branch switch to '{target_branch}'...",
"lastError": "",
"lastBranch": target_branch,
"lastMode": "branch-switch",
"startedAt": time.time(),
"finishedAt": 0.0,
"progressStep": 1,
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
"progressStepPercent": 0.0,
"progressPercent": 0.0,
"progressLabel": "Preparing branch switch",
"progressDetail": "Initializing branch switch...",
})
threading.Thread(target=_branch_switch_worker, args=(target_branch,), daemon=True).start()
return jsonify({
"message": f"Branch switch started for '{target_branch}'. Device will reboot when complete.",
"warning": "Fast update skips backup creation and finalization safeguards.",
}), 202
# ── Galaxy pairing (mirrors settings.cc L262-282) ──────────────────
GALAXY_DIR = Path("/data/galaxy")
GALAXY_AUTH_FILE = GALAXY_DIR / "glxyauth"