feat: restart to apply update, privileged helper, rollback

This commit is contained in:
fsylvain committed 2026-10-01 21:36:40 +02:00
1 parent 70460e4170
commit 54e8fd5fed
63 files changed
+8635 -164

No files matched your search

+149 -77
View File
@@ -4,17 +4,19 @@ import ReleaseNotes from '@/components/ReleaseNotes.vue'
import ServerCommand from '@/components/ServerCommand.vue'
import UpdateProgress from '@/components/UpdateProgress.vue'
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
import type { UpdateStatus } from '@/composables/useUpdate'
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
import {
ENABLE_BROWSER_UPDATES_COMMAND,
VERIFYING_ANNOUNCEMENT,
browserUpdatesDocsUrl,
displayVersion,
downloadAnnouncement,
formatDownloadProgress,
formatLastChecked,
formatReleased,
formatUpdatedAt,
} from '@/lib/updateFormat'
type ApplyView =
@@ -24,9 +26,10 @@ type ApplyView =
| 'ready'
| 'download-failed'
| 'verification-failed'
| 'rolled-back'
| 'limited'
type ButtonAction = 'check' | 'update'
type ButtonAction = 'check' | 'update' | 'restart'
interface Block {
tone: UpdateTone
@@ -42,6 +45,7 @@ interface ActionButton {
primary: boolean
disabled: boolean
action: ButtonAction | null
restartKind?: RestartKind
}
interface ProgressView {
@@ -51,8 +55,15 @@ interface ProgressView {
}
const AVAILABLE_HINT = 'Takes about 2 minutes. Your site stays online.'
const RESTART_HINT = 'Restart takes about 10 seconds. Your site is unavailable while it restarts.'
const READY_HINT =
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.'
const LIMITED_BLOCK: Block = {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
}
const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
const props = defineProps<{
@@ -67,10 +78,22 @@ const props = defineProps<{
const emit = defineEmits<{
check: []
update: []
restart: [kind: RestartKind]
}>()
const card = ref<HTMLElement | null>(null)
const heading = ref<HTMLElement | null>(null)
function focusHeading() {
heading.value?.focus()
}
function focusRestart() {
card.value?.querySelector<HTMLElement>('[data-testid="restart-button"]')?.focus()
}
defineExpose({ focusHeading, focusRestart })
const isOwner = computed(() => props.role === 'owner')
const newVersion = computed(() => displayVersion(props.status.latest))
const lastChecked = computed(() =>
@@ -97,11 +120,18 @@ const applyView = computed<ApplyView | null>(() => {
return state
}
if (state === 'failed') {
return error === 'verification' ? 'verification-failed' : 'download-failed'
return failedView(error)
}
return available ? 'available' : null
})
function failedView(error: UpdateStatus['error']): ApplyView {
if (error === 'verification') {
return 'verification-failed'
}
return error === 'rolled_back' || error === 'install' ? 'rolled-back' : 'download-failed'
}
const isStarting = computed(() => props.applyPhase === 'starting')
const downloadFailed = computed(() => applyView.value === 'download-failed' && !isStarting.value)
@@ -152,12 +182,14 @@ const applyBlocks = computed<Record<ApplyView, Block>>(() => ({
title: "We didn't use this update",
text: "The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
},
limited: {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
'rolled-back': {
tone: 'warning',
icon: 'i-lucide-triangle-alert',
title: "The update didn't install",
text: 'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.',
meta: 'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.',
},
limited: LIMITED_BLOCK,
}))
const progressView = computed<ProgressView | null>(() => {
@@ -186,7 +218,10 @@ const hint = computed(() => {
if (applyView.value === 'available') {
return AVAILABLE_HINT
}
return applyView.value === 'ready' ? READY_HINT : null
if (applyView.value === 'ready') {
return READY_HINT
}
return showRestartHint.value ? RESTART_HINT : null
})
function applyButton(view: ApplyView): ActionButton {
@@ -204,26 +239,13 @@ function applyButton(view: ApplyView): ActionButton {
label: 'Restart',
icon: 'i-lucide-power',
primary: true,
disabled: true,
action: null,
disabled: false,
action: 'restart',
restartKind: 'update',
}
}
if (view === 'limited') {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
action: 'update',
}
return limitedButton('update')
}
if (view === 'available') {
return {
@@ -253,12 +275,7 @@ const block = computed<Block>(() => {
}
}
if (isOwner.value && props.phase === 'limited') {
return {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
}
return LIMITED_BLOCK
}
if (isOwner.value && props.phase === 'failed') {
return {
@@ -272,6 +289,15 @@ const block = computed<Block>(() => {
if (applyView.value) {
return applyBlocks.value[applyView.value]
}
if (isDone.value) {
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: `You're on version ${displayVersion(props.status.current)}`,
text: 'The update is installed and your site is running again.',
meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
}
}
if (props.status.available && isOwner.value) {
return {
tone: 'info',
@@ -299,6 +325,14 @@ const block = computed<Block>(() => {
}
})
const isDone = computed(() => props.status.state === 'succeeded')
const isRestarting = computed(() => props.status.state === 'restarting')
const restartAvailable = computed(
() => canApplyView.value && (props.status.state === 'idle' || isDone.value),
)
const showServerGuidance = computed(
() =>
isOwner.value &&
@@ -307,68 +341,91 @@ const showServerGuidance = computed(
props.phase === 'idle',
)
const actionButton = computed<ActionButton | null>(() => {
if (!isOwner.value) {
return null
const plainRestartButton: ActionButton = {
label: 'Restart',
icon: 'i-lucide-power',
primary: false,
disabled: false,
action: 'restart',
restartKind: 'restart',
}
const actionButtons = computed<ActionButton[]>(() => {
if (!isOwner.value || isRestarting.value) {
return []
}
if (props.phase === 'checking') {
return {
label: 'Checking…',
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
action: null,
}
return [
{
label: 'Checking…',
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
action: null,
},
]
}
if (props.phase === 'failed') {
return {
return withPlainRestart({
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
action: 'check',
}
})
}
if (props.phase === 'limited') {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
action: 'check',
}
return [limitedButton('check')]
}
if (applyView.value) {
return applyButton(applyView.value)
return [applyButton(applyView.value)]
}
if (props.status.available) {
return null
if (props.status.available && !isDone.value) {
return []
}
return {
return withPlainRestart({
label: 'Check for updates',
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
action: 'check',
}
})
})
function onAction() {
const action = actionButton.value?.action
if (action === 'check') {
const showRestartHint = computed(() => actionButtons.value.includes(plainRestartButton))
function withPlainRestart(button: ActionButton) {
return restartAvailable.value ? [button, plainRestartButton] : [button]
}
function limitedButton(action: ButtonAction): ActionButton {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
action,
}
}
function onAction(button: ActionButton) {
if (button.action === 'check') {
emit('check')
}
if (action === 'update') {
if (button.action === 'update') {
emit('update')
}
if (button.action === 'restart' && button.restartKind) {
emit('restart', button.restartKind)
}
}
const showNotes = computed(
@@ -390,6 +447,7 @@ const notes = computed(() => {
<template>
<section
ref="card"
aria-labelledby="updates-h"
data-testid="updates-card"
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
@@ -427,22 +485,25 @@ const notes = computed(() => {
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
<div v-if="actionButton" class="flex flex-col gap-2 md:flex-row md:items-center">
<div v-if="actionButtons.length" class="flex flex-col gap-2 md:flex-row md:items-center">
<UButton
:color="actionButton.primary ? 'primary' : 'neutral'"
:variant="actionButton.primary ? 'solid' : 'outline'"
:icon="actionButton.icon"
:disabled="actionButton.disabled"
v-for="button in actionButtons"
:key="button.label"
:data-testid="button.action === 'restart' ? 'restart-button' : undefined"
:color="button.primary ? 'primary' : 'neutral'"
:variant="button.primary ? 'solid' : 'outline'"
:icon="button.icon"
:disabled="button.disabled"
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
:class="
actionButton.primary
button.primary
? ''
: 'border border-accented bg-default text-highlighted ring-0'
"
:ui="{ leadingIcon: 'size-4' }"
@click="onAction"
@click="onAction(button)"
>
{{ actionButton.label }}
{{ button.label }}
</UButton>
</div>
@@ -450,7 +511,7 @@ const notes = computed(() => {
<a
v-if="showServerGuidance && status.release_url"
:href="status.release_url"
:href="browserUpdatesDocsUrl(status.release_url)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
@@ -458,5 +519,16 @@ const notes = computed(() => {
How to update
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
<a
v-if="isDone && status.release_url"
:href="status.release_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
Read the release notes
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
</section>
</template>