Follow-up to 56d4875b, same v1.7.0-alpha shipping band.
Backend download timeout bumped from 300s to 1800s (update.rs) with an
explicit 30s connect timeout. git.tx1138.com raw-file throughput can sit
around 70–80 KB/s, which meant OTA downloads were timing out at ~55%
through the 40 MB binary even though the SHA would have matched on a
full pull. 30 min gives ample headroom for the worst LAN-to-VPS link we
actually hit.
Frontend: SystemUpdate.vue now formats downloadPercent with toFixed(2)
via a new computed, so the progress card shows "45.23%" instead of
"45.270894%". Cosmetic only; the underlying ref still tracks raw floats.
Manifest changelog rewritten in user-facing language per the saved
feedback — no file paths, function names, or "root cause" phrasing.
Artifacts refreshed:
binary d85a71c5…982f4 40360936
frontend 8adcdacf…e687f6 76986852
ISO at image-recipe/results/archipelago-installer-unbundled-x86_64.iso
(Apr 20 09:00) carries both fixes for fresh installs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
470 lines
16 KiB
Vue
470 lines
16 KiB
Vue
<template>
|
|
<div class="pb-6">
|
|
<div class="mb-6">
|
|
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
|
|
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
|
|
</div>
|
|
|
|
<!-- Status message -->
|
|
<div
|
|
v-if="statusMessage"
|
|
class="mb-4 p-3 rounded-lg text-sm"
|
|
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
|
>
|
|
{{ statusMessage }}
|
|
</div>
|
|
|
|
<!-- Current Version -->
|
|
<div class="glass-card p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.currentSystem') }}</h2>
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div class="p-4 bg-white/5 rounded-lg">
|
|
<p class="text-xs text-white/60 mb-1">{{ t('common.version') }}</p>
|
|
<p class="text-xl font-bold text-white">v{{ currentVersion }}</p>
|
|
</div>
|
|
<div class="p-4 bg-white/5 rounded-lg">
|
|
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
|
|
<p class="text-sm font-medium text-white">{{ lastCheckDisplay }}</p>
|
|
</div>
|
|
<div class="p-4 bg-white/5 rounded-lg">
|
|
<p class="text-xs text-white/60 mb-1">{{ t('common.status') }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-2 h-2 rounded-full" :class="statusDotColor"></div>
|
|
<p class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Available Update -->
|
|
<div v-if="updateInfo" class="glass-card p-6 mb-6 border border-orange-400/30">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
|
|
<p class="text-sm text-white/60">Version {{ updateInfo.version }} — {{ updateInfo.release_date }}</p>
|
|
</div>
|
|
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
|
|
</div>
|
|
|
|
<!-- Changelog -->
|
|
<div v-if="updateInfo.changelog.length" class="mb-4">
|
|
<h3 class="text-sm font-medium text-white/80 mb-2">{{ t('systemUpdate.changelog') }}</h3>
|
|
<ul class="space-y-1">
|
|
<li v-for="(entry, i) in updateInfo.changelog" :key="i" class="text-sm text-white/60 flex gap-2">
|
|
<span class="text-orange-400 shrink-0">•</span>
|
|
<span>{{ entry }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Components -->
|
|
<div v-if="updateInfo.components > 0" class="mb-4 p-3 bg-white/5 rounded-lg">
|
|
<p class="text-xs text-white/60">{{ t('systemUpdate.componentsToUpdate', { count: updateInfo.components }) }}</p>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-3">
|
|
<!-- Git path: one-shot pull+rebuild+restart -->
|
|
<button
|
|
v-if="updateMethod === 'git' && !applying"
|
|
@click="requestGitApply"
|
|
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
|
>
|
|
{{ t('systemUpdate.pullAndRebuild') }}
|
|
</button>
|
|
<!-- Manifest path: download then apply -->
|
|
<button
|
|
v-if="updateMethod !== 'git' && !downloading && !applying && !downloaded"
|
|
@click="downloadUpdate"
|
|
class="glass-button rounded-lg px-6 py-2 text-sm font-medium"
|
|
>
|
|
{{ t('systemUpdate.downloadUpdate') }}
|
|
</button>
|
|
<button
|
|
v-if="updateMethod !== 'git' && downloaded && !applying"
|
|
@click="requestApply"
|
|
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
|
>
|
|
{{ t('systemUpdate.applyUpdate') }}
|
|
</button>
|
|
</div>
|
|
<p v-if="updateMethod === 'git'" class="text-xs text-white/40 mt-3">
|
|
{{ t('systemUpdate.gitMethodHint') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- No update available -->
|
|
<div v-else-if="!loading" class="glass-card p-6 mb-6">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.upToDate') }}</h2>
|
|
</div>
|
|
<p class="text-sm text-white/60">{{ t('systemUpdate.upToDateMessage') }}</p>
|
|
</div>
|
|
|
|
<!-- Download Progress -->
|
|
<div v-if="downloading" class="glass-card p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
|
|
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
|
|
<div
|
|
class="h-full bg-orange-400 rounded-full transition-all duration-500"
|
|
:style="{ width: downloadPercentFormatted + '%' }"
|
|
></div>
|
|
</div>
|
|
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}</p>
|
|
</div>
|
|
|
|
<!-- Applying -->
|
|
<div v-if="applying" class="glass-card p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
|
|
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Update Schedule -->
|
|
<div class="glass-card p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-white mb-2">{{ t('systemUpdate.updateSchedule') }}</h2>
|
|
<p class="text-sm text-white/60 mb-4">{{ t('systemUpdate.subtitle') }}</p>
|
|
<div class="space-y-3">
|
|
<label
|
|
v-for="opt in scheduleOptions"
|
|
:key="opt.value"
|
|
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
|
|
:class="{ 'ring-1 ring-orange-400/50 bg-orange-500/10': schedule === opt.value }"
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="update-schedule"
|
|
:value="opt.value"
|
|
:checked="schedule === opt.value"
|
|
@change="setSchedule(opt.value)"
|
|
class="mt-1 accent-orange-400"
|
|
/>
|
|
<div>
|
|
<p class="text-sm font-medium text-white">{{ opt.label }}</p>
|
|
<p class="text-xs text-white/50">{{ opt.description }}</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions row -->
|
|
<div class="glass-card p-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
|
|
<div class="flex flex-wrap gap-3">
|
|
<button
|
|
@click="checkForUpdates"
|
|
:disabled="loading"
|
|
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
|
>
|
|
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
|
|
</button>
|
|
<button
|
|
v-if="rollbackAvailable"
|
|
@click="requestRollback"
|
|
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20"
|
|
>
|
|
{{ t('systemUpdate.rollback') }}
|
|
</button>
|
|
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center">
|
|
{{ t('systemUpdate.backToSettings') }}
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirmation modal -->
|
|
<Transition name="fade">
|
|
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
|
|
<div class="glass-card p-6 max-w-sm w-full mx-4">
|
|
<h3 class="text-lg font-semibold text-white mb-3">
|
|
{{ confirmAction === 'rollback'
|
|
? t('systemUpdate.rollbackTitle')
|
|
: confirmAction === 'git-apply'
|
|
? t('systemUpdate.gitApplyTitle')
|
|
: t('systemUpdate.applyTitle') }}
|
|
</h3>
|
|
<p class="text-sm text-white/70 mb-6">
|
|
{{ confirmAction === 'rollback'
|
|
? t('systemUpdate.rollbackMessage')
|
|
: confirmAction === 'git-apply'
|
|
? t('systemUpdate.gitApplyMessage')
|
|
: t('systemUpdate.applyMessage') }}
|
|
</p>
|
|
<div class="flex gap-3 justify-end">
|
|
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
@click="executeConfirm"
|
|
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
|
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
|
>
|
|
{{ confirmAction === 'rollback'
|
|
? t('systemUpdate.rollbackButton')
|
|
: confirmAction === 'git-apply'
|
|
? t('systemUpdate.pullAndRebuild')
|
|
: t('systemUpdate.applyNow') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { RouterLink } from 'vue-router'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
|
|
interface UpdateDetail {
|
|
version: string
|
|
release_date: string
|
|
changelog: string[]
|
|
components: number
|
|
}
|
|
|
|
type ScheduleValue = 'manual' | 'daily_check' | 'auto_apply'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const scheduleOptions = computed<{ value: ScheduleValue; label: string; description: string }[]>(() => [
|
|
{ value: 'manual', label: t('systemUpdate.manualOnly'), description: t('systemUpdate.manualOnlyDesc') },
|
|
{ value: 'daily_check', label: t('systemUpdate.dailyCheck'), description: t('systemUpdate.dailyCheckDesc') },
|
|
{ value: 'auto_apply', label: t('systemUpdate.autoApply'), description: t('systemUpdate.autoApplyDesc') },
|
|
])
|
|
|
|
const schedule = ref<ScheduleValue>('daily_check')
|
|
const loading = ref(false)
|
|
const downloading = ref(false)
|
|
const downloaded = ref(false)
|
|
const applying = ref(false)
|
|
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | null>(null)
|
|
const currentVersion = ref('0.0.0')
|
|
const lastCheck = ref<string | null>(null)
|
|
const updateInfo = ref<UpdateDetail | null>(null)
|
|
const updateMethod = ref<'git' | 'manifest' | null>(null)
|
|
const rollbackAvailable = ref(false)
|
|
const updateInProgress = ref(false)
|
|
const statusMessage = ref('')
|
|
const statusIsError = ref(false)
|
|
const downloadPercent = ref(0)
|
|
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
|
|
|
|
const lastCheckDisplay = computed(() => {
|
|
if (!lastCheck.value) return t('common.never')
|
|
try {
|
|
const d = new Date(lastCheck.value)
|
|
return d.toLocaleString()
|
|
} catch {
|
|
return lastCheck.value
|
|
}
|
|
})
|
|
|
|
const statusLabel = computed(() => {
|
|
if (applying.value) return t('systemUpdate.applying')
|
|
if (downloading.value) return t('systemUpdate.downloading')
|
|
if (updateInProgress.value) return t('systemUpdate.applying')
|
|
if (updateInfo.value) return t('systemUpdate.updateAvailable')
|
|
if (rollbackAvailable.value) return t('systemUpdate.rollback')
|
|
return t('systemUpdate.upToDate')
|
|
})
|
|
|
|
const statusDotColor = computed(() => {
|
|
if (applying.value || downloading.value) return 'bg-orange-400 animate-pulse'
|
|
if (updateInfo.value || updateInProgress.value) return 'bg-orange-400'
|
|
return 'bg-green-400'
|
|
})
|
|
|
|
const statusTextColor = computed(() => {
|
|
if (applying.value || downloading.value || updateInfo.value || updateInProgress.value) return 'text-orange-400'
|
|
return 'text-green-400'
|
|
})
|
|
|
|
function showStatus(msg: string, isError = false) {
|
|
statusMessage.value = msg
|
|
statusIsError.value = isError
|
|
setTimeout(() => { statusMessage.value = '' }, 8000)
|
|
}
|
|
|
|
async function loadStatus() {
|
|
try {
|
|
const res = await rpcClient.call<{
|
|
current_version: string
|
|
last_check: string | null
|
|
update_available: boolean
|
|
update_in_progress: boolean
|
|
rollback_available: boolean
|
|
}>({ method: 'update.status' })
|
|
currentVersion.value = res.current_version
|
|
lastCheck.value = res.last_check
|
|
updateInProgress.value = res.update_in_progress
|
|
rollbackAvailable.value = res.rollback_available
|
|
|
|
if (res.update_in_progress) {
|
|
downloaded.value = true
|
|
}
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.warn('Failed to load update status', e)
|
|
}
|
|
}
|
|
|
|
async function checkForUpdates() {
|
|
loading.value = true
|
|
statusMessage.value = ''
|
|
try {
|
|
const res = await rpcClient.call<{
|
|
current_version: string
|
|
last_check: string | null
|
|
update_available: boolean
|
|
update: UpdateDetail | null
|
|
update_method?: string
|
|
}>({ method: 'update.check' })
|
|
currentVersion.value = res.current_version
|
|
lastCheck.value = res.last_check
|
|
updateInfo.value = res.update
|
|
updateMethod.value = res.update_method === 'git' ? 'git' : 'manifest'
|
|
if (!res.update_available) {
|
|
showStatus(t('systemUpdate.upToDateMessage'))
|
|
}
|
|
} catch (e) {
|
|
showStatus(t('systemUpdate.checkFailed'), true)
|
|
if (import.meta.env.DEV) console.warn('Update check failed', e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function downloadUpdate() {
|
|
downloading.value = true
|
|
downloadPercent.value = 0
|
|
statusMessage.value = ''
|
|
|
|
// Simulate incremental progress while waiting for the RPC
|
|
const progressInterval = setInterval(() => {
|
|
if (downloadPercent.value < 90) {
|
|
downloadPercent.value += Math.random() * 15
|
|
}
|
|
}, 500)
|
|
|
|
try {
|
|
const res = await rpcClient.call<{
|
|
total_bytes: number
|
|
downloaded_bytes: number
|
|
components_downloaded: number
|
|
}>({ method: 'update.download' })
|
|
downloadPercent.value = 100
|
|
downloaded.value = true
|
|
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
|
showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB }))
|
|
} catch (e) {
|
|
showStatus(t('systemUpdate.downloadFailed'), true)
|
|
if (import.meta.env.DEV) console.warn('Download failed', e)
|
|
} finally {
|
|
clearInterval(progressInterval)
|
|
downloading.value = false
|
|
}
|
|
}
|
|
|
|
function requestApply() {
|
|
confirmAction.value = 'apply'
|
|
}
|
|
|
|
function requestGitApply() {
|
|
confirmAction.value = 'git-apply'
|
|
}
|
|
|
|
function requestRollback() {
|
|
confirmAction.value = 'rollback'
|
|
}
|
|
|
|
function cancelConfirm() {
|
|
confirmAction.value = null
|
|
}
|
|
|
|
async function executeConfirm() {
|
|
const action = confirmAction.value
|
|
confirmAction.value = null
|
|
if (action === 'apply') {
|
|
await applyUpdate()
|
|
} else if (action === 'git-apply') {
|
|
await applyUpdateGit()
|
|
} else if (action === 'rollback') {
|
|
await rollbackUpdate()
|
|
}
|
|
}
|
|
|
|
async function applyUpdateGit() {
|
|
applying.value = true
|
|
statusMessage.value = ''
|
|
try {
|
|
await rpcClient.call({ method: 'update.git-apply' })
|
|
showStatus(t('systemUpdate.gitApplyStarted'))
|
|
updateInfo.value = null
|
|
} catch (e) {
|
|
showStatus(t('systemUpdate.applyFailed'), true)
|
|
if (import.meta.env.DEV) console.warn('Git apply failed', e)
|
|
} finally {
|
|
applying.value = false
|
|
}
|
|
}
|
|
|
|
async function applyUpdate() {
|
|
applying.value = true
|
|
statusMessage.value = ''
|
|
try {
|
|
await rpcClient.call({ method: 'update.apply' })
|
|
showStatus(t('systemUpdate.applySuccess'))
|
|
updateInfo.value = null
|
|
downloaded.value = false
|
|
await loadStatus()
|
|
} catch (e) {
|
|
showStatus(t('systemUpdate.applyFailed'), true)
|
|
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
|
} finally {
|
|
applying.value = false
|
|
}
|
|
}
|
|
|
|
async function rollbackUpdate() {
|
|
try {
|
|
await rpcClient.call({ method: 'update.rollback' })
|
|
showStatus(t('systemUpdate.rollbackSuccess'))
|
|
rollbackAvailable.value = false
|
|
await loadStatus()
|
|
} catch (e) {
|
|
showStatus(t('systemUpdate.rollbackFailed'), true)
|
|
if (import.meta.env.DEV) console.warn('Rollback failed', e)
|
|
}
|
|
}
|
|
|
|
async function loadSchedule() {
|
|
try {
|
|
const res = await rpcClient.call<{ schedule: ScheduleValue }>({ method: 'update.get-schedule' })
|
|
schedule.value = res.schedule
|
|
} catch {
|
|
if (import.meta.env.DEV) console.warn('Failed to load update schedule')
|
|
}
|
|
}
|
|
|
|
async function setSchedule(value: ScheduleValue) {
|
|
schedule.value = value
|
|
try {
|
|
await rpcClient.call({ method: 'update.set-schedule', params: { schedule: value } })
|
|
showStatus(`Schedule set to ${scheduleOptions.value.find(o => o.value === value)?.label}`)
|
|
} catch (e) {
|
|
showStatus('Failed to save schedule', true)
|
|
if (import.meta.env.DEV) console.warn('Set schedule failed', e)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
|
|
})
|
|
</script>
|