feat: update button (download, verify, stage)

This commit is contained in:
fsylvain committed 2026-10-01 18:13:45 +02:00
1 parent 834493a4f5
commit 70460e4170
41 files changed
+5345 -97

No files matched your search

+23 -5
View File
@@ -1,11 +1,29 @@
<script setup lang="ts">
export type UpdateBannerState = 'available'
import { computed } from 'vue'
defineProps<{
export type UpdateBannerState = 'available' | 'ready'
const props = defineProps<{
state: UpdateBannerState
version: string
}>()
const COPY = {
available: {
icon: 'i-lucide-circle-arrow-up',
title: (version: string) => `Version ${version} is available`,
description: 'Update it from Settings. It takes about 2 minutes and the site stays online.',
},
ready: {
icon: 'i-lucide-circle-check',
title: (version: string) => `Version ${version} is ready to install`,
description:
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
},
} as const
const copy = computed(() => COPY[props.state])
const emit = defineEmits<{
'go-to-settings': []
dismiss: []
@@ -19,9 +37,9 @@ const emit = defineEmits<{
color="info"
variant="soft"
orientation="horizontal"
icon="i-lucide-circle-arrow-up"
:title="`Version ${version} is available`"
description="Update it from Settings. It takes about 2 minutes and the site stays online."
:icon="copy.icon"
:title="copy.title(version)"
:description="copy.description"
:ui="{
root: 'flex-wrap items-start gap-x-2.5 gap-y-3 border border-default py-3 ring-0 md:flex-nowrap md:items-center',
wrapper: 'basis-0 gap-0.5',
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
label: string
text: string
value: number | null
}>()
</script>
<template>
<div aria-live="off" class="flex flex-col gap-1.5 pt-2">
<UProgress
:model-value="value"
:max="100"
:get-value-label="() => label"
:ui="{
base: 'h-1.5 bg-accented',
indicator: value === null ? 'h-full w-7/20 bg-primary' : 'bg-primary',
}"
/>
<span class="font-mono text-xs/4 text-muted">{{ text }}</span>
</div>
</template>
+3 -1
View File
@@ -17,6 +17,7 @@ const props = defineProps<{
title: string
text: string
meta?: string
live?: 'polite' | 'off'
}>()
const toneClasses = computed(() => TONE_CLASSES[props.tone])
@@ -24,7 +25,7 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
</script>
<template>
<div aria-live="polite" class="flex gap-3 p-3.5" :class="toneClasses.block">
<div :aria-live="live ?? 'polite'" class="flex gap-3 p-3.5" :class="toneClasses.block">
<span class="flex pt-0.5">
<UIcon
:name="icon"
@@ -38,6 +39,7 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-base font-semibold text-highlighted">{{ title }}</span>
<span class="text-sm text-pretty text-toned">{{ text }}</span>
<slot />
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
</div>
</div>
+260 -8
View File
@@ -1,18 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
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 { UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import type { UpdateCheckPhase } from '@/stores/update'
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
import {
ENABLE_BROWSER_UPDATES_COMMAND,
VERIFYING_ANNOUNCEMENT,
displayVersion,
downloadAnnouncement,
formatDownloadProgress,
formatLastChecked,
formatReleased,
} from '@/lib/updateFormat'
type ApplyView =
| 'available'
| 'downloading'
| 'verifying'
| 'ready'
| 'download-failed'
| 'verification-failed'
| 'limited'
type ButtonAction = 'check' | 'update'
interface Block {
tone: UpdateTone
icon: string
@@ -26,26 +41,208 @@ interface ActionButton {
icon: string
primary: boolean
disabled: boolean
action: ButtonAction | null
}
interface ProgressView {
label: string
text: string
value: number | null
}
const AVAILABLE_HINT = 'Takes about 2 minutes. Your site stays online.'
const READY_HINT =
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.'
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: []
}>()
const heading = ref<HTMLElement | null>(null)
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 error === 'verification' ? 'verification-failed' : 'download-failed'
}
return available ? 'available' : null
})
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',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Update downloads the new version and checks it. Nothing changes until you restart.`,
meta: lastChecked.value,
},
downloading: {
tone: 'info',
icon: 'i-lucide-download',
title: `Downloading version ${newVersion.value}…`,
text: 'You can leave this page. The download continues and your site stays online.',
},
verifying: {
tone: 'info',
icon: 'i-lucide-shield-check',
title: 'Checking the download…',
text: 'Making sure the file is complete and safe to install. This takes a few seconds.',
},
ready: {
tone: 'success',
icon: 'i-lucide-circle-check',
title: `Version ${newVersion.value} is ready to install`,
text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
meta: 'Nothing has changed yet.',
},
'download-failed': {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "The download didn't finish",
text: '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',
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.',
},
}))
const progressView = computed<ProgressView | null>(() => {
if (applyView.value === 'downloading') {
const progress = props.status.progress ?? ZERO_PROGRESS
return {
label: `Downloading version ${newVersion.value}`,
text: props.status.progress ? formatDownloadProgress(progress) : '0%',
value: progress.percent,
}
}
if (applyView.value === 'verifying') {
return { label: VERIFYING_ANNOUNCEMENT, text: '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' ? VERIFYING_ANNOUNCEMENT : ''
})
const hint = computed(() => {
if (applyView.value === 'available') {
return AVAILABLE_HINT
}
return applyView.value === 'ready' ? READY_HINT : null
})
function applyButton(view: ApplyView): ActionButton {
if (isStarting.value || view === 'downloading' || view === 'verifying') {
return {
label: 'Updating…',
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
action: null,
}
}
if (view === 'ready') {
return {
label: 'Restart',
icon: 'i-lucide-power',
primary: true,
disabled: true,
action: null,
}
}
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',
}
}
if (view === 'available') {
return {
label: 'Update',
icon: 'i-lucide-download',
primary: true,
disabled: false,
action: 'update',
}
}
return {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
action: 'update',
}
}
const block = computed<Block>(() => {
if (isOwner.value && props.phase === 'checking') {
return {
@@ -72,6 +269,9 @@ const block = computed<Block>(() => {
meta: lastChecked.value,
}
}
if (applyView.value) {
return applyBlocks.value[applyView.value]
}
if (props.status.available && isOwner.value) {
return {
tone: 'info',
@@ -100,7 +300,11 @@ const block = computed<Block>(() => {
})
const showServerGuidance = computed(
() => isOwner.value && props.status.available && props.phase === 'idle',
() =>
isOwner.value &&
!props.status.can_apply &&
props.status.available &&
props.phase === 'idle',
)
const actionButton = computed<ActionButton | null>(() => {
@@ -113,10 +317,17 @@ const actionButton = computed<ActionButton | null>(() => {
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
action: null,
}
}
if (props.phase === 'failed') {
return { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: true, disabled: false }
return {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
action: 'check',
}
}
if (props.phase === 'limited') {
return props.retryActive
@@ -125,8 +336,18 @@ const actionButton = computed<ActionButton | null>(() => {
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: false, disabled: false }
: {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
action: 'check',
}
}
if (applyView.value) {
return applyButton(applyView.value)
}
if (props.status.available) {
return null
@@ -136,12 +357,28 @@ const actionButton = computed<ActionButton | null>(() => {
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
action: 'check',
}
})
function onAction() {
const action = actionButton.value?.action
if (action === 'check') {
emit('check')
}
if (action === 'update') {
emit('update')
}
}
const showNotes = computed(
() =>
showServerGuidance.value || applyView.value === 'available' || applyView.value === 'ready',
)
const notes = computed(() => {
const items = props.status.notes?.items ?? []
if (!showServerGuidance.value || items.length === 0 || !props.status.notes) {
if (!showNotes.value || items.length === 0 || !props.status.notes) {
return null
}
return {
@@ -159,6 +396,7 @@ const notes = computed(() => {
>
<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"
>
@@ -171,7 +409,19 @@ const notes = computed(() => {
:title="block.title"
:text="block.text"
:meta="block.meta"
/>
:live="progressView ? 'off' : 'polite'"
>
<UpdateProgress
v-if="progressView"
:label="progressView.label"
:text="progressView.text"
:value="progressView.value"
/>
</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" />
@@ -190,12 +440,14 @@ const notes = computed(() => {
: 'border border-accented bg-default text-highlighted ring-0'
"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('check')"
@click="onAction"
>
{{ actionButton.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="status.release_url"