Files
RenewCMS/web/src/components/UpdatesCard.vue
T

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>