718 lines
17 KiB
Vue
718 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
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 { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
|
import type { UserRole } from '@/composables/useApi'
|
|
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
|
|
import {
|
|
CONTAINER_UPDATE_COMMAND,
|
|
ENABLE_BROWSER_UPDATES_COMMAND,
|
|
VERIFYING_ANNOUNCEMENT,
|
|
browserUpdatesDocsUrl,
|
|
containerUpdatesDocsUrl,
|
|
displayVersion,
|
|
downloadAnnouncement,
|
|
formatDownloadProgress,
|
|
formatLastChecked,
|
|
formatReleased,
|
|
formatUpdatedAt,
|
|
} from '@/lib/updateFormat'
|
|
|
|
type ApplyView =
|
|
| 'available'
|
|
| 'downloading'
|
|
| 'verifying'
|
|
| 'ready'
|
|
| 'download-failed'
|
|
| 'verification-failed'
|
|
| 'rolled-back'
|
|
| 'limited'
|
|
|
|
type ButtonAction = 'check' | 'update' | 'restart'
|
|
|
|
interface Block {
|
|
tone: UpdateTone
|
|
icon: string
|
|
title: string
|
|
text: string
|
|
meta?: string
|
|
tag?: string
|
|
}
|
|
|
|
interface ActionButton {
|
|
label: string
|
|
icon: string
|
|
primary: boolean
|
|
disabled: boolean
|
|
action: ButtonAction | null
|
|
restartKind?: RestartKind
|
|
}
|
|
|
|
interface ProgressView {
|
|
label: string
|
|
text: string
|
|
value: number | null
|
|
}
|
|
|
|
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',
|
|
get title() {
|
|
return t('Too many tries')
|
|
},
|
|
get text() {
|
|
return t('Wait a moment before trying again.')
|
|
},
|
|
}
|
|
const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
|
|
|
|
const props = defineProps<{
|
|
status: UpdateStatus
|
|
role: UserRole | undefined
|
|
phase: UpdateCheckPhase
|
|
applyPhase: UpdateApplyPhase
|
|
retryLabel: string
|
|
retryActive: boolean
|
|
}>()
|
|
|
|
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(() =>
|
|
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
|
|
)
|
|
|
|
const canApplyView = computed(() => isOwner.value && props.status.can_apply)
|
|
|
|
const applyView = computed<ApplyView | null>(() => {
|
|
if (!canApplyView.value || props.phase !== 'idle') {
|
|
return null
|
|
}
|
|
if (props.applyPhase === 'limited') {
|
|
return 'limited'
|
|
}
|
|
if (props.applyPhase === 'failed') {
|
|
return 'download-failed'
|
|
}
|
|
if (props.applyPhase === 'untrusted') {
|
|
return 'verification-failed'
|
|
}
|
|
const { state, error, available } = props.status
|
|
if (state === 'downloading' || state === 'verifying' || state === 'ready') {
|
|
return state
|
|
}
|
|
if (state === '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)
|
|
|
|
watch(downloadFailed, async (failed) => {
|
|
if (failed) {
|
|
await nextTick()
|
|
heading.value?.focus()
|
|
}
|
|
})
|
|
|
|
const applyBlocks = computed<Record<ApplyView, Block>>(() => ({
|
|
available: {
|
|
tone: 'info',
|
|
icon: 'i-lucide-circle-arrow-up',
|
|
get title() {
|
|
return t('Version {0} is available', [newVersion.value])
|
|
},
|
|
get text() {
|
|
return t(
|
|
'You have {0}. Update downloads the new version and checks it. Nothing changes until you restart.',
|
|
[props.status.current],
|
|
)
|
|
},
|
|
meta: lastChecked.value,
|
|
},
|
|
downloading: {
|
|
tone: 'info',
|
|
icon: 'i-lucide-download',
|
|
get title() {
|
|
return t('Downloading version {0}…', [newVersion.value])
|
|
},
|
|
get text() {
|
|
return t('You can leave this page. The download continues and your site stays online.')
|
|
},
|
|
},
|
|
verifying: {
|
|
tone: 'info',
|
|
icon: 'i-lucide-shield-check',
|
|
get title() {
|
|
return t('Checking the download…')
|
|
},
|
|
get text() {
|
|
return t(
|
|
'Making sure the file is complete and safe to install. This takes a few seconds.',
|
|
)
|
|
},
|
|
},
|
|
ready: {
|
|
tone: 'warning',
|
|
icon: 'i-lucide-clock',
|
|
get tag() {
|
|
return t('Pending')
|
|
},
|
|
get title() {
|
|
return t('Version {0} is ready to install', [newVersion.value])
|
|
},
|
|
get text() {
|
|
return t(
|
|
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
|
|
)
|
|
},
|
|
meta: t('Nothing has changed yet.'),
|
|
},
|
|
'download-failed': {
|
|
tone: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return t("The download didn't finish")
|
|
},
|
|
get text() {
|
|
return t(
|
|
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
|
|
)
|
|
},
|
|
},
|
|
'verification-failed': {
|
|
tone: 'warning',
|
|
icon: 'i-lucide-triangle-alert',
|
|
get title() {
|
|
return t("We didn't use this update")
|
|
},
|
|
get text() {
|
|
return t(
|
|
"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.",
|
|
)
|
|
},
|
|
},
|
|
'rolled-back': {
|
|
tone: 'warning',
|
|
icon: 'i-lucide-triangle-alert',
|
|
get title() {
|
|
return t("The update didn't install")
|
|
},
|
|
get text() {
|
|
return t(
|
|
'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.',
|
|
)
|
|
},
|
|
meta: t(
|
|
'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>(() => {
|
|
if (applyView.value === 'downloading') {
|
|
const progress = props.status.progress ?? ZERO_PROGRESS
|
|
return {
|
|
get label() {
|
|
return t('Downloading version {0}', [newVersion.value])
|
|
},
|
|
text: props.status.progress ? formatDownloadProgress(progress) : '0%',
|
|
value: progress.percent,
|
|
}
|
|
}
|
|
if (applyView.value === 'verifying') {
|
|
return {
|
|
get label() {
|
|
return t(VERIFYING_ANNOUNCEMENT)
|
|
},
|
|
get text() {
|
|
return t('Almost there')
|
|
},
|
|
value: null,
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
|
|
const announcement = computed(() => {
|
|
if (applyView.value === 'downloading') {
|
|
return downloadAnnouncement(newVersion.value, props.status.progress?.percent ?? 0)
|
|
}
|
|
return applyView.value === 'verifying' ? t(VERIFYING_ANNOUNCEMENT) : ''
|
|
})
|
|
|
|
const hint = computed(() => {
|
|
if (applyView.value === 'available') {
|
|
return t(AVAILABLE_HINT)
|
|
}
|
|
if (applyView.value === 'ready') {
|
|
return t(READY_HINT)
|
|
}
|
|
return showRestartHint.value ? t(RESTART_HINT) : null
|
|
})
|
|
|
|
function applyButton(view: ApplyView): ActionButton {
|
|
if (isStarting.value || view === 'downloading' || view === 'verifying') {
|
|
return {
|
|
get label() {
|
|
return t('Updating…')
|
|
},
|
|
icon: 'i-lucide-loader-circle',
|
|
primary: false,
|
|
disabled: true,
|
|
action: null,
|
|
}
|
|
}
|
|
if (view === 'ready') {
|
|
return {
|
|
get label() {
|
|
return t('Restart')
|
|
},
|
|
icon: 'i-lucide-power',
|
|
primary: true,
|
|
disabled: false,
|
|
action: 'restart',
|
|
restartKind: 'update',
|
|
}
|
|
}
|
|
if (view === 'limited') {
|
|
return limitedButton('update')
|
|
}
|
|
if (view === 'available') {
|
|
return {
|
|
get label() {
|
|
return t('Update')
|
|
},
|
|
icon: 'i-lucide-download',
|
|
primary: true,
|
|
disabled: false,
|
|
action: 'update',
|
|
}
|
|
}
|
|
return {
|
|
get label() {
|
|
return t('Try again')
|
|
},
|
|
icon: 'i-lucide-rotate-cw',
|
|
primary: true,
|
|
disabled: false,
|
|
action: 'update',
|
|
}
|
|
}
|
|
|
|
const block = computed<Block>(() => {
|
|
if (isOwner.value && props.phase === 'checking') {
|
|
return {
|
|
tone: 'neutral',
|
|
icon: 'i-lucide-loader-circle',
|
|
get title() {
|
|
return t('Checking for updates…')
|
|
},
|
|
get text() {
|
|
return t('This usually takes a few seconds.')
|
|
},
|
|
}
|
|
}
|
|
if (isOwner.value && props.phase === 'limited') {
|
|
return LIMITED_BLOCK
|
|
}
|
|
if (isOwner.value && props.phase === 'failed') {
|
|
return {
|
|
tone: 'warning',
|
|
icon: 'i-lucide-wifi-off',
|
|
get title() {
|
|
return t("Couldn't check for updates")
|
|
},
|
|
get text() {
|
|
return t(
|
|
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
|
|
)
|
|
},
|
|
meta: lastChecked.value,
|
|
}
|
|
}
|
|
if (applyView.value) {
|
|
return applyBlocks.value[applyView.value]
|
|
}
|
|
if (isDone.value) {
|
|
return {
|
|
tone: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t("You're on version {0}", [displayVersion(props.status.current)])
|
|
},
|
|
get text() {
|
|
return t('The update is installed and your site is running again.')
|
|
},
|
|
meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
|
|
}
|
|
}
|
|
if (showContainerGuidance.value) {
|
|
return {
|
|
tone: 'info',
|
|
icon: 'i-lucide-info',
|
|
get title() {
|
|
return t('Version {0} is available', [newVersion.value])
|
|
},
|
|
get text() {
|
|
return t(
|
|
'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
|
|
[props.status.current],
|
|
)
|
|
},
|
|
}
|
|
}
|
|
if (props.status.available && isOwner.value) {
|
|
return {
|
|
tone: 'info',
|
|
icon: 'i-lucide-info',
|
|
get title() {
|
|
return t('Version {0} is available', [newVersion.value])
|
|
},
|
|
get text() {
|
|
return t(
|
|
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
|
|
)
|
|
},
|
|
}
|
|
}
|
|
if (props.status.available) {
|
|
return {
|
|
tone: 'info',
|
|
icon: 'i-lucide-circle-arrow-up',
|
|
get title() {
|
|
return t('Version {0} is available', [newVersion.value])
|
|
},
|
|
get text() {
|
|
return t(
|
|
'You have {0}. Only the owner can update RenewCMS. Ask the owner of this site.',
|
|
[props.status.current],
|
|
)
|
|
},
|
|
}
|
|
}
|
|
return {
|
|
tone: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t("You're up to date")
|
|
},
|
|
get text() {
|
|
return isOwner.value
|
|
? t('You have the latest version of RenewCMS.')
|
|
: t('Only the owner can update or restart the site.')
|
|
},
|
|
meta: isOwner.value ? lastChecked.value : undefined,
|
|
}
|
|
})
|
|
|
|
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 showContainerGuidance = computed(
|
|
() =>
|
|
isOwner.value && props.status.container && props.status.available && props.phase === 'idle',
|
|
)
|
|
|
|
const showServerGuidance = computed(
|
|
() =>
|
|
isOwner.value &&
|
|
!props.status.container &&
|
|
!props.status.can_apply &&
|
|
props.status.available &&
|
|
props.phase === 'idle',
|
|
)
|
|
|
|
const plainRestartButton: ActionButton = {
|
|
get label() {
|
|
return t('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 [
|
|
{
|
|
get label() {
|
|
return t('Checking…')
|
|
},
|
|
icon: 'i-lucide-loader-circle',
|
|
primary: false,
|
|
disabled: true,
|
|
action: null,
|
|
},
|
|
]
|
|
}
|
|
if (props.phase === 'failed') {
|
|
return withPlainRestart({
|
|
get label() {
|
|
return t('Try again')
|
|
},
|
|
icon: 'i-lucide-rotate-cw',
|
|
primary: true,
|
|
disabled: false,
|
|
action: 'check',
|
|
})
|
|
}
|
|
if (props.phase === 'limited') {
|
|
return [limitedButton('check')]
|
|
}
|
|
if (applyView.value) {
|
|
return [applyButton(applyView.value)]
|
|
}
|
|
if (props.status.available && !isDone.value) {
|
|
return []
|
|
}
|
|
return withPlainRestart({
|
|
get label() {
|
|
return t('Check for updates')
|
|
},
|
|
icon: 'i-lucide-refresh-cw',
|
|
primary: false,
|
|
disabled: false,
|
|
action: 'check',
|
|
})
|
|
})
|
|
|
|
const inBlockButton = computed(() =>
|
|
applyView.value === 'ready' ? (actionButtons.value[0] ?? null) : null,
|
|
)
|
|
|
|
const rowButtons = computed(() => (inBlockButton.value ? [] : actionButtons.value))
|
|
|
|
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
|
|
? {
|
|
get label() {
|
|
return t('Try again in {0}', [props.retryLabel])
|
|
},
|
|
icon: 'i-lucide-clock',
|
|
primary: false,
|
|
disabled: true,
|
|
action: null,
|
|
}
|
|
: {
|
|
get label() {
|
|
return t('Try again')
|
|
},
|
|
icon: 'i-lucide-rotate-cw',
|
|
primary: false,
|
|
disabled: false,
|
|
action,
|
|
}
|
|
}
|
|
|
|
function onAction(button: ActionButton) {
|
|
if (button.action === 'check') {
|
|
emit('check')
|
|
}
|
|
if (button.action === 'update') {
|
|
emit('update')
|
|
}
|
|
if (button.action === 'restart' && button.restartKind) {
|
|
emit('restart', button.restartKind)
|
|
}
|
|
}
|
|
|
|
const showNotes = computed(
|
|
() =>
|
|
showServerGuidance.value ||
|
|
showContainerGuidance.value ||
|
|
applyView.value === 'available' ||
|
|
applyView.value === 'ready',
|
|
)
|
|
|
|
const notes = computed(() => {
|
|
const items = props.status.notes?.items ?? []
|
|
if (!showNotes.value || items.length === 0 || !props.status.notes) {
|
|
return null
|
|
}
|
|
return {
|
|
items,
|
|
date: formatReleased(props.status.notes.released_at),
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<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"
|
|
>
|
|
<h2
|
|
id="updates-h"
|
|
ref="heading"
|
|
tabindex="-1"
|
|
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
|
|
>
|
|
{{ t('Updates') }}
|
|
</h2>
|
|
|
|
<UpdateStatusBlock
|
|
:tone="block.tone"
|
|
:icon="block.icon"
|
|
:title="block.title"
|
|
:text="block.text"
|
|
:meta="block.meta"
|
|
:tag="block.tag"
|
|
:live="progressView ? 'off' : 'polite'"
|
|
>
|
|
<UpdateProgress
|
|
v-if="progressView"
|
|
:label="progressView.label"
|
|
:text="progressView.text"
|
|
:value="progressView.value"
|
|
/>
|
|
<template v-if="inBlockButton" #action>
|
|
<UButton
|
|
:data-testid="inBlockButton.action === 'restart' ? 'restart-button' : undefined"
|
|
:color="inBlockButton.primary ? 'primary' : 'neutral'"
|
|
:variant="inBlockButton.primary ? 'solid' : 'outline'"
|
|
:icon="inBlockButton.icon"
|
|
:disabled="inBlockButton.disabled"
|
|
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
|
|
:class="
|
|
inBlockButton.primary
|
|
? ''
|
|
: 'border border-accented bg-default text-highlighted ring-0'
|
|
"
|
|
:ui="{ leadingIcon: 'size-4' }"
|
|
@click="onAction(inBlockButton)"
|
|
>
|
|
{{ inBlockButton.label }}
|
|
</UButton>
|
|
</template>
|
|
</UpdateStatusBlock>
|
|
|
|
<p v-if="canApplyView" role="status" data-testid="update-announcer" class="sr-only">
|
|
{{ announcement }}
|
|
</p>
|
|
|
|
<ReleaseNotes v-if="notes" :version="newVersion" :date="notes.date" :items="notes.items" />
|
|
|
|
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
|
|
|
|
<ServerCommand
|
|
v-if="showContainerGuidance"
|
|
:line="CONTAINER_UPDATE_COMMAND"
|
|
:show-note="false"
|
|
/>
|
|
|
|
<div v-if="rowButtons.length" class="flex flex-col gap-2 md:flex-row md:items-center">
|
|
<UButton
|
|
v-for="button in rowButtons"
|
|
: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="
|
|
button.primary
|
|
? ''
|
|
: 'border border-accented bg-default text-highlighted ring-0'
|
|
"
|
|
:ui="{ leadingIcon: 'size-4' }"
|
|
@click="onAction(button)"
|
|
>
|
|
{{ button.label }}
|
|
</UButton>
|
|
</div>
|
|
|
|
<span v-if="hint" class="text-xs/4.5 text-pretty text-muted">{{ hint }}</span>
|
|
|
|
<a
|
|
v-if="showServerGuidance && 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"
|
|
>
|
|
{{ t('How to update') }}
|
|
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
|
</a>
|
|
|
|
<a
|
|
v-if="showContainerGuidance && status.release_url"
|
|
:href="containerUpdatesDocsUrl(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"
|
|
>
|
|
{{ t('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"
|
|
>
|
|
{{ t('Read the release notes') }}
|
|
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
|
</a>
|
|
</section>
|
|
</template>
|