feat: update button (download, verify, stage)
This commit is contained in:
1 parent
834493a4f5
commit
70460e4170
41 files changed
+5345
-97
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user