feat: restart to apply update, privileged helper, rollback
This commit is contained in:
1 parent
70460e4170
commit
54e8fd5fed
63 files changed
+8635
-164
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user