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
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import type { RestartKind } from '@/composables/useUpdate'
|
||||
import { formatStartedAt, formatStartedWithAge } from '@/lib/updateFormat'
|
||||
|
||||
const LOGO_SIZE = 26
|
||||
|
||||
const props = defineProps<{
|
||||
kind: RestartKind | null
|
||||
version: string
|
||||
startedAt: string
|
||||
slow: boolean
|
||||
now: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reload: []
|
||||
}>()
|
||||
|
||||
const copy = computed(() => {
|
||||
if (props.slow) {
|
||||
return {
|
||||
title: 'Still restarting',
|
||||
text: 'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.',
|
||||
}
|
||||
}
|
||||
if (props.kind === 'update') {
|
||||
return {
|
||||
title: `Installing version ${props.version}`,
|
||||
text: 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Restarting your site',
|
||||
text: 'This takes about 10 seconds. This page reloads on its own when the site is back.',
|
||||
}
|
||||
})
|
||||
|
||||
const meta = computed(() =>
|
||||
props.slow
|
||||
? formatStartedWithAge(props.startedAt, new Date(props.now))
|
||||
: formatStartedAt(props.startedAt),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
data-testid="restart-overlay"
|
||||
class="fixed inset-0 z-70 flex items-center justify-center bg-muted p-6"
|
||||
>
|
||||
<div class="flex w-full max-w-110 flex-col items-start gap-4">
|
||||
<AppLogo :size="LOGO_SIZE" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h2 class="text-xl/7.5 font-semibold text-highlighted" data-testid="restart-title">
|
||||
{{ copy.title }}
|
||||
</h2>
|
||||
<span class="text-base/5.5 text-pretty text-toned" data-testid="restart-text">{{
|
||||
copy.text
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Restarting"
|
||||
class="h-1 w-full overflow-hidden bg-accented"
|
||||
>
|
||||
<div class="restart-bar h-1 w-7/20 bg-primary" />
|
||||
</div>
|
||||
<span class="text-xs text-muted" data-testid="restart-meta">{{ meta }}</span>
|
||||
<UButton
|
||||
v-if="slow"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-3.5 text-highlighted ring-0 md:h-8"
|
||||
@click="emit('reload')"
|
||||
>
|
||||
Reload page
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.restart-bar {
|
||||
margin-left: 30%;
|
||||
animation: restart-slide 1.6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.restart-bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes restart-slide {
|
||||
from {
|
||||
margin-left: 0;
|
||||
}
|
||||
to {
|
||||
margin-left: 65%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -21,7 +21,9 @@ export interface UpdateProgress {
|
||||
total_bytes: number
|
||||
}
|
||||
|
||||
export type UpdateErrorKind = 'download' | 'verification'
|
||||
export type UpdateErrorKind = 'download' | 'verification' | 'rolled_back' | 'install'
|
||||
|
||||
export type RestartKind = 'update' | 'restart'
|
||||
|
||||
export interface UpdateStatus {
|
||||
current: string
|
||||
@@ -35,6 +37,7 @@ export interface UpdateStatus {
|
||||
progress: UpdateProgress | null
|
||||
error: UpdateErrorKind | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
export function useUpdate() {
|
||||
@@ -52,5 +55,36 @@ export function useUpdate() {
|
||||
return request<UpdateStatus>('/v1/system/update/download', { method: 'POST' })
|
||||
}
|
||||
|
||||
return { getUpdateStatus, checkForUpdate, startUpdateDownload }
|
||||
function requestRestart(kind: RestartKind): Promise<UpdateStatus> {
|
||||
return request<UpdateStatus>('/v1/system/update/restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind }),
|
||||
})
|
||||
}
|
||||
|
||||
function pollUpdateStatus(): Promise<UpdateStatus> {
|
||||
return request<UpdateStatus>('/v1/system/update', {
|
||||
onUnauthorized: 'passthrough',
|
||||
onForbidden: 'passthrough',
|
||||
})
|
||||
}
|
||||
|
||||
async function isSiteUp(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/healthz', { cache: 'no-store' })
|
||||
return response.status === 200
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getUpdateStatus,
|
||||
checkForUpdate,
|
||||
startUpdateDownload,
|
||||
requestRestart,
|
||||
pollUpdateStatus,
|
||||
isSiteUp,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AUTO_CHECK_INTERVAL_MS,
|
||||
browserUpdatesDocsUrl,
|
||||
displayVersion,
|
||||
downloadAnnouncement,
|
||||
formatDownloadProgress,
|
||||
formatLastChecked,
|
||||
formatReleased,
|
||||
formatStartedAt,
|
||||
formatStartedWithAge,
|
||||
formatUpdatedAt,
|
||||
isCheckDue,
|
||||
} from '@/lib/updateFormat'
|
||||
|
||||
@@ -92,3 +96,42 @@ describe('downloadAnnouncement', () => {
|
||||
expect(downloadAnnouncement('2.3.0-beta', percent)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserUpdatesDocsUrl', () => {
|
||||
it('points the release url at the Browser updates section of the docs of the same repository', () => {
|
||||
expect(
|
||||
browserUpdatesDocsUrl('https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta'),
|
||||
).toBe(
|
||||
'https://git.example/fsylvain/RenewCMS/src/branch/main/docs/deployment.md#browser-updates',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a url it cannot interpret', () => {
|
||||
expect(browserUpdatesDocsUrl('https://git.example/somewhere')).toBe(
|
||||
'https://git.example/somewhere',
|
||||
)
|
||||
expect(browserUpdatesDocsUrl('not a url')).toBe('not a url')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restart meta lines', () => {
|
||||
const startedAt = '2026-09-30T09:51:00Z'
|
||||
|
||||
it('reads the start time', () => {
|
||||
expect(formatStartedAt(startedAt)).toBe('Started at 09:51')
|
||||
})
|
||||
|
||||
it('adds how long ago it was', () => {
|
||||
expect(formatStartedWithAge(startedAt, new Date('2026-09-30T09:52:30Z'))).toBe(
|
||||
'Started at 09:51 · 1 minute ago',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2026-09-30T09:52:00Z', 'Updated today at 09:52'],
|
||||
['2026-09-29T18:12:00Z', 'Updated yesterday at 18:12'],
|
||||
['2026-09-20T18:12:00Z', 'Updated 20 Sep 2026 at 18:12'],
|
||||
])('reads the update time %s as %s', (iso, expected) => {
|
||||
expect(formatUpdatedAt(iso, new Date('2026-09-30T10:00:00Z'))).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { RestartKind } from '@/composables/useUpdate'
|
||||
|
||||
export const RESTART_MEMORY_KEY = 'renewcms:update-restart'
|
||||
|
||||
export interface RestartMemory {
|
||||
kind: RestartKind
|
||||
started_at: string
|
||||
}
|
||||
|
||||
export function readRestartMemory(): RestartMemory | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RESTART_MEMORY_KEY)
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<RestartMemory>
|
||||
const validKind = parsed.kind === 'update' || parsed.kind === 'restart'
|
||||
return validKind && typeof parsed.started_at === 'string'
|
||||
? { kind: parsed.kind as RestartKind, started_at: parsed.started_at }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeRestartMemory(memory: RestartMemory) {
|
||||
try {
|
||||
sessionStorage.setItem(RESTART_MEMORY_KEY, JSON.stringify(memory))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRestartMemory() {
|
||||
try {
|
||||
sessionStorage.removeItem(RESTART_MEMORY_KEY)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function isLaterThan(candidate: string | null, reference: string) {
|
||||
if (candidate === null) {
|
||||
return false
|
||||
}
|
||||
return new Date(candidate).getTime() > new Date(reference).getTime()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
|
||||
export const ENABLE_BROWSER_UPDATES_COMMAND = 'renewcms enable-browser-updates'
|
||||
export const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
@@ -15,7 +15,7 @@ function isSameLocalDay(a: Date, b: Date) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatClock(date: Date) {
|
||||
export function formatClock(date: Date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
@@ -69,3 +69,37 @@ export function downloadAnnouncement(version: string, percent: number) {
|
||||
}
|
||||
|
||||
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
|
||||
|
||||
const BROWSER_UPDATES_DOCS_PATH = '/src/branch/main/docs/deployment.md#browser-updates'
|
||||
const RELEASES_SEGMENT = /^(.*)\/releases\//
|
||||
|
||||
export function browserUpdatesDocsUrl(releaseUrl: string) {
|
||||
try {
|
||||
const url = new URL(releaseUrl)
|
||||
const repository = RELEASES_SEGMENT.exec(url.pathname)
|
||||
return repository ? `${url.origin}${repository[1]}${BROWSER_UPDATES_DOCS_PATH}` : releaseUrl
|
||||
} catch {
|
||||
return releaseUrl
|
||||
}
|
||||
}
|
||||
|
||||
export function formatStartedAt(iso: string) {
|
||||
return `Started at ${formatClock(new Date(iso))}`
|
||||
}
|
||||
|
||||
export function formatStartedWithAge(iso: string, now: Date = new Date()) {
|
||||
return `${formatStartedAt(iso)} · ${formatRelativeDate(iso, now)}`
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(iso: string, now: Date = new Date()) {
|
||||
const date = new Date(iso)
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (isSameLocalDay(date, now)) {
|
||||
return `Updated today at ${formatClock(date)}`
|
||||
}
|
||||
if (isSameLocalDay(date, yesterday)) {
|
||||
return `Updated yesterday at ${formatClock(date)}`
|
||||
}
|
||||
return `Updated ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
|
||||
}
|
||||
+141
-2
@@ -1,7 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useUpdate, type UpdateStatus } from '@/composables/useUpdate'
|
||||
import { useUpdate, type RestartKind, type UpdateStatus } from '@/composables/useUpdate'
|
||||
import { displayVersion } from '@/lib/updateFormat'
|
||||
import { reloadPage } from '@/lib/reloadPage'
|
||||
import {
|
||||
clearRestartMemory,
|
||||
isLaterThan,
|
||||
readRestartMemory,
|
||||
writeRestartMemory,
|
||||
} from '@/lib/restartMemory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
@@ -18,8 +26,20 @@ export type UpdateStartResult =
|
||||
| 'conflict'
|
||||
| 'busy'
|
||||
|
||||
export type RestartRequestResult = 'started' | 'failed' | 'forbidden' | 'conflict' | 'unauthorized'
|
||||
|
||||
export interface RestartRun {
|
||||
kind: RestartKind | null
|
||||
startedAt: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export type RestartOutcome = 'updated' | 'rolled-back' | 'restarted'
|
||||
|
||||
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
|
||||
const DEFAULT_RETRY_SECONDS = 60
|
||||
const RESTART_POLL_INTERVAL_MS = 1000
|
||||
const RESTART_SLOW_AFTER_SECONDS = 60
|
||||
|
||||
function readDismissedVersion() {
|
||||
try {
|
||||
@@ -50,8 +70,9 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const available = computed(() => status.value?.available === true)
|
||||
const showCue = computed(() => isOwner.value && available.value)
|
||||
const restarting = computed(() => status.value?.state === 'restarting')
|
||||
const bannerVisible = computed(
|
||||
() => showCue.value && status.value?.latest !== dismissedVersion.value,
|
||||
() => showCue.value && !restarting.value && status.value?.latest !== dismissedVersion.value,
|
||||
)
|
||||
const jobRunning = computed(
|
||||
() =>
|
||||
@@ -60,6 +81,13 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
(status.value.state === 'downloading' || status.value.state === 'verifying'),
|
||||
)
|
||||
const readyToInstall = computed(() => isOwner.value && status.value?.state === 'ready')
|
||||
const restartRun = ref<RestartRun | null>(null)
|
||||
const restartSlow = ref(false)
|
||||
const restartNow = ref(Date.now())
|
||||
let restartTimer: ReturnType<typeof setInterval> | null = null
|
||||
let restartInFlight = false
|
||||
let restartSilentSeconds = 0
|
||||
let restartReloaded = false
|
||||
|
||||
function load() {
|
||||
if (pendingLoad) {
|
||||
@@ -165,6 +193,109 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRestart(kind: RestartKind): Promise<RestartRequestResult> {
|
||||
const { requestRestart: sendRestart } = useUpdate()
|
||||
try {
|
||||
const next = await sendRestart(kind)
|
||||
status.value = next
|
||||
const startedAt = next.started_at ?? new Date().toISOString()
|
||||
writeRestartMemory({ kind, started_at: startedAt })
|
||||
beginRestart({ kind, startedAt, version: displayVersion(next.latest) })
|
||||
return 'started'
|
||||
} catch (error) {
|
||||
return restartFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
function restartFailure(error: unknown): RestartRequestResult {
|
||||
const outcomes: Record<number, RestartRequestResult> = {
|
||||
401: 'unauthorized',
|
||||
403: 'forbidden',
|
||||
409: 'conflict',
|
||||
}
|
||||
return (error instanceof ApiError ? outcomes[error.status] : undefined) ?? 'failed'
|
||||
}
|
||||
|
||||
function resumeRestart() {
|
||||
if (!isOwner.value || restartRun.value || status.value?.state !== 'restarting') {
|
||||
return
|
||||
}
|
||||
const remembered = readRestartMemory()
|
||||
beginRestart({
|
||||
kind: remembered?.kind ?? null,
|
||||
startedAt: status.value.started_at ?? new Date().toISOString(),
|
||||
version: displayVersion(status.value.latest),
|
||||
})
|
||||
}
|
||||
|
||||
function beginRestart(run: RestartRun) {
|
||||
stopRestart()
|
||||
restartRun.value = run
|
||||
restartSlow.value = false
|
||||
restartSilentSeconds = 0
|
||||
restartReloaded = false
|
||||
restartTimer = setInterval(watchRestart, RESTART_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopRestart() {
|
||||
if (restartTimer !== null) {
|
||||
clearInterval(restartTimer)
|
||||
restartTimer = null
|
||||
}
|
||||
restartRun.value = null
|
||||
restartSlow.value = false
|
||||
}
|
||||
|
||||
async function watchRestart() {
|
||||
if (restartInFlight || !restartRun.value) {
|
||||
return
|
||||
}
|
||||
restartInFlight = true
|
||||
const run = restartRun.value
|
||||
const { isSiteUp, pollUpdateStatus } = useUpdate()
|
||||
try {
|
||||
const [siteUp, answer] = await Promise.all([
|
||||
isSiteUp(),
|
||||
pollUpdateStatus().catch(() => null),
|
||||
])
|
||||
settleRestartPoll(run, siteUp, answer)
|
||||
} finally {
|
||||
restartInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
function settleRestartPoll(run: RestartRun, siteUp: boolean, answer: UpdateStatus | null) {
|
||||
restartNow.value = Date.now()
|
||||
restartSilentSeconds = siteUp || answer ? 0 : restartSilentSeconds + 1
|
||||
restartSlow.value = restartSilentSeconds >= RESTART_SLOW_AFTER_SECONDS
|
||||
if (!siteUp || !answer || answer.state === 'restarting') {
|
||||
return
|
||||
}
|
||||
if (isLaterThan(answer.finished_at, run.startedAt) && !restartReloaded) {
|
||||
restartReloaded = true
|
||||
reloadPage()
|
||||
}
|
||||
}
|
||||
|
||||
function settleRememberedRestart(): RestartOutcome | null {
|
||||
const remembered = readRestartMemory()
|
||||
const current = status.value
|
||||
if (!remembered || !current || current.state === 'restarting') {
|
||||
return null
|
||||
}
|
||||
if (!isLaterThan(current.finished_at, remembered.started_at)) {
|
||||
return null
|
||||
}
|
||||
clearRestartMemory()
|
||||
if (current.state === 'succeeded') {
|
||||
return 'updated'
|
||||
}
|
||||
if (current.state === 'failed') {
|
||||
return 'rolled-back'
|
||||
}
|
||||
return remembered.kind === 'restart' ? 'restarted' : null
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
if (status.value) {
|
||||
dismissedVersion.value = status.value.latest
|
||||
@@ -184,6 +315,14 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
bannerVisible,
|
||||
jobRunning,
|
||||
readyToInstall,
|
||||
restarting,
|
||||
restartRun,
|
||||
restartSlow,
|
||||
restartNow,
|
||||
requestRestart,
|
||||
resumeRestart,
|
||||
stopRestart,
|
||||
settleRememberedRestart,
|
||||
load,
|
||||
poll,
|
||||
startDownload,
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import UpdatesCard from '@/components/UpdatesCard.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { useSettings } from '@/composables/useSettings'
|
||||
import type { UpdateStatus } from '@/composables/useUpdate'
|
||||
import { isCheckDue } from '@/lib/updateFormat'
|
||||
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import { reloadPage } from '@/lib/reloadPage'
|
||||
import { displayVersion, isCheckDue } from '@/lib/updateFormat'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useUpdateStore } from '@/stores/update'
|
||||
import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '@/stores/update'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const RESTART_FAILED_COPY =
|
||||
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment."
|
||||
const RESTART_WARNING =
|
||||
"Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first."
|
||||
const PLAIN_RESTART_DIALOG = {
|
||||
title: 'Restart the site?',
|
||||
description: RESTART_WARNING,
|
||||
confirmLabel: 'Restart site',
|
||||
cancelLabel: 'Cancel',
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
|
||||
const { getSettings } = useSettings()
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const retry = useRetryAfter()
|
||||
|
||||
const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const version = ref('')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -74,6 +89,7 @@ function neverCheckedStatus(): UpdateStatus {
|
||||
progress: null,
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +158,95 @@ async function onCheck() {
|
||||
await reloadAfterRoleChange()
|
||||
}
|
||||
|
||||
function installRestartDialog() {
|
||||
const version = displayVersion(update.status?.latest ?? '')
|
||||
return {
|
||||
title: 'Restart to finish the update?',
|
||||
description: `RenewCMS will install version ${version} and restart. ${RESTART_WARNING}`,
|
||||
confirmLabel: 'Restart now',
|
||||
cancelLabel: 'Restart later',
|
||||
}
|
||||
}
|
||||
|
||||
async function onRestart(kind: RestartKind) {
|
||||
const attempt = { result: 'failed' as RestartRequestResult }
|
||||
const dialog = kind === 'update' ? installRestartDialog() : PLAIN_RESTART_DIALOG
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
...dialog,
|
||||
pendingLabel: 'Restarting…',
|
||||
tone: 'neutral',
|
||||
action: async () => {
|
||||
attempt.result = await update.requestRestart(kind)
|
||||
if (attempt.result === 'failed') {
|
||||
throw new Error(RESTART_FAILED_COPY)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
.open()
|
||||
|
||||
if (attempt.result === 'started') {
|
||||
return
|
||||
}
|
||||
if (!confirmed) {
|
||||
await nextTick()
|
||||
updatesCard.value?.focusRestart()
|
||||
return
|
||||
}
|
||||
await settleRefusedRestart(attempt.result)
|
||||
}
|
||||
|
||||
async function settleRefusedRestart(result: RestartRequestResult) {
|
||||
if (result === 'forbidden') {
|
||||
notifyOnlyOwner()
|
||||
await reloadAfterRoleChange()
|
||||
return
|
||||
}
|
||||
if (result !== 'conflict') {
|
||||
return
|
||||
}
|
||||
await update.load()
|
||||
if (update.jobRunning || update.restarting) {
|
||||
toast.add({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'An update is already running',
|
||||
description: 'Another owner started it. Progress shows here.',
|
||||
})
|
||||
}
|
||||
update.resumeRestart()
|
||||
}
|
||||
|
||||
const OUTCOME_TOASTS = {
|
||||
updated: () => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Update installed',
|
||||
description: `RenewCMS is now on version ${displayVersion(update.status?.current ?? '')}.`,
|
||||
}),
|
||||
'rolled-back': () => ({
|
||||
color: 'error' as const,
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "The update didn't install",
|
||||
description: `Your site is back on ${update.status?.current ?? ''}.`,
|
||||
duration: 0,
|
||||
}),
|
||||
restarted: () => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Site restarted',
|
||||
description: 'Your site is running again.',
|
||||
}),
|
||||
}
|
||||
|
||||
async function announceRestartOutcome(outcome: RestartOutcome) {
|
||||
toast.add(OUTCOME_TOASTS[outcome]())
|
||||
await nextTick()
|
||||
updatesCard.value?.focusHeading()
|
||||
}
|
||||
|
||||
const updateRequested = ref(false)
|
||||
let autoCheckHandled = false
|
||||
|
||||
@@ -198,17 +303,32 @@ watch(
|
||||
onMounted(async () => {
|
||||
loadSettings()
|
||||
await update.load()
|
||||
update.resumeRestart()
|
||||
const outcome = update.settleRememberedRestart()
|
||||
if (outcome) {
|
||||
await announceRestartOutcome(outcome)
|
||||
}
|
||||
updateRequested.value = true
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clearSkeletonTimer()
|
||||
stopPolling()
|
||||
update.stopRestart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<div class="flex max-w-164 flex-col gap-5">
|
||||
<RestartOverlay
|
||||
v-if="update.restartRun"
|
||||
:kind="update.restartRun.kind"
|
||||
:version="update.restartRun.version"
|
||||
:started-at="update.restartRun.startedAt"
|
||||
:slow="update.restartSlow"
|
||||
:now="update.restartNow"
|
||||
@reload="reloadPage"
|
||||
/>
|
||||
<div class="flex max-w-164 flex-col gap-5" :inert="update.restartRun !== null">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
|
||||
<p class="text-muted">Workspace information.</p>
|
||||
@@ -262,6 +382,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<UpdatesCard
|
||||
v-if="updatesStatus"
|
||||
ref="updatesCard"
|
||||
:status="updatesStatus"
|
||||
:role="session.session?.role"
|
||||
:phase="updatesPhase"
|
||||
@@ -270,6 +391,7 @@ onBeforeUnmount(() => {
|
||||
:retry-active="retry.active.value"
|
||||
@check="onCheck"
|
||||
@update="onUpdate"
|
||||
@restart="onRestart"
|
||||
/>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
|
||||
@@ -8,7 +8,15 @@ import type { UpdateStatus } from '@/composables/useUpdate'
|
||||
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import HomeView from '../HomeView.vue'
|
||||
import { NEW_TAG, downloading, newerAvailable, ready, startable, verifying } from './updateTestKit'
|
||||
import {
|
||||
NEW_TAG,
|
||||
downloading,
|
||||
newerAvailable,
|
||||
ready,
|
||||
restarting,
|
||||
startable,
|
||||
verifying,
|
||||
} from './updateTestKit'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
@@ -145,6 +153,22 @@ describe('Home banner, ready to install (owner)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home banner while restarting', () => {
|
||||
it('shows no update banner and does not poll', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
await mountHome('owner', restarting())
|
||||
|
||||
expect(banner()).toBeNull()
|
||||
const reads = () =>
|
||||
vi.mocked(global.fetch).mock.calls.filter(([url]) => url === '/v1/system/update')
|
||||
const readsAfterMount = reads().length
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
expect(reads()).toHaveLength(readsAfterMount)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sidebar pill and mobile dot during a job', () => {
|
||||
it.each([
|
||||
{ name: 'downloading', status: downloading() },
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -290,7 +290,7 @@ describe('Settings, owner, Verifying', () => {
|
||||
})
|
||||
|
||||
describe('Settings, owner, Ready to install', () => {
|
||||
it('shows the success block, a disabled Restart, the notes and the hint, and stops polling', async () => {
|
||||
it('shows the success block, an enabled Restart, the notes and the hint, and stops polling', async () => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
@@ -310,18 +310,15 @@ describe('Settings, owner, Ready to install', () => {
|
||||
)
|
||||
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
|
||||
const restart = findButton('Restart') as HTMLButtonElement
|
||||
expect(restart.disabled).toBe(true)
|
||||
expect(restart.disabled).toBe(false)
|
||||
|
||||
const readsBefore = callsTo(STATUS_PATH).length
|
||||
restart.click()
|
||||
await flushPromises()
|
||||
await tick(3000)
|
||||
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore)
|
||||
expect(requestMock.mock.calls.map(([path]) => path)).not.toContain(
|
||||
'/v1/system/update/restart',
|
||||
)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
CHECK_PATH,
|
||||
NEW_TAG,
|
||||
NOTE_ITEMS,
|
||||
RELEASE_URL,
|
||||
RUNNING_VERSION,
|
||||
STATUS_PATH,
|
||||
findButton,
|
||||
@@ -257,7 +256,9 @@ describe('Settings, owner, newer version, not prepared', () => {
|
||||
const link = Array.from(updatesCard().querySelectorAll('a')).find(
|
||||
(anchor) => anchor.textContent?.trim() === 'How to update',
|
||||
)!
|
||||
expect(link.getAttribute('href')).toBe(RELEASE_URL)
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://git.example/fsylvain/RenewCMS/src/branch/main/docs/deployment.md#browser-updates',
|
||||
)
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
expect(link.getAttribute('rel')).toContain('noopener')
|
||||
expect(cardButtons()).toEqual(['Copy'])
|
||||
@@ -441,7 +442,7 @@ describe('A check runs on the first open after 24 hours, for the owner only', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('No Update, Restart or progress element exists in this slice', () => {
|
||||
describe('No Update, Restart or progress element exists when the server is not prepared or for admins', () => {
|
||||
it.each([
|
||||
{ name: 'up to date', role: 'owner' as const, status: upToDate() },
|
||||
{ name: 'not prepared', role: 'owner' as const, status: newerAvailable() },
|
||||
|
||||
@@ -14,6 +14,7 @@ export const NOTE_ITEMS = [
|
||||
export const STATUS_PATH = '/v1/system/update'
|
||||
export const CHECK_PATH = '/v1/system/update/check'
|
||||
export const DOWNLOAD_PATH = '/v1/system/update/download'
|
||||
export const RESTART_PATH = '/v1/system/update/restart'
|
||||
|
||||
export const BOARD_TOTAL_BYTES = 27262976
|
||||
export const BOARD_DONE_BYTES = 11534336
|
||||
@@ -35,6 +36,7 @@ export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
|
||||
progress: null,
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -94,3 +96,40 @@ export function ready(): UpdateStatus {
|
||||
export function failedWith(error: 'download' | 'verification'): UpdateStatus {
|
||||
return startable({ state: 'failed', error, started_at: '2026-09-30T09:51:00Z' })
|
||||
}
|
||||
|
||||
export const RESTART_STARTED_AT = '2026-09-30T09:51:00Z'
|
||||
export const RESTART_FINISHED_AT = '2026-09-30T09:52:00Z'
|
||||
|
||||
export function restarting(): UpdateStatus {
|
||||
return startable({ state: 'restarting', started_at: RESTART_STARTED_AT })
|
||||
}
|
||||
|
||||
export function updateSucceeded(): UpdateStatus {
|
||||
return upToDate({
|
||||
current: NEW_TAG,
|
||||
latest: NEW_TAG,
|
||||
available: false,
|
||||
can_apply: true,
|
||||
state: 'succeeded',
|
||||
started_at: RESTART_STARTED_AT,
|
||||
finished_at: RESTART_FINISHED_AT,
|
||||
})
|
||||
}
|
||||
|
||||
export function rolledBack(error: 'rolled_back' | 'install' = 'rolled_back'): UpdateStatus {
|
||||
return startable({
|
||||
state: 'failed',
|
||||
error,
|
||||
started_at: RESTART_STARTED_AT,
|
||||
finished_at: RESTART_FINISHED_AT,
|
||||
})
|
||||
}
|
||||
|
||||
export function plainRestartDone(): UpdateStatus {
|
||||
return upToDate({
|
||||
can_apply: true,
|
||||
state: 'idle',
|
||||
started_at: null,
|
||||
finished_at: RESTART_FINISHED_AT,
|
||||
})
|
||||
}
|
||||
+89
-9
@@ -187,6 +187,7 @@ function updateStatusBody(overrides: Record<string, unknown> = {}) {
|
||||
progress: null,
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -248,6 +249,41 @@ function updateDownloading() {
|
||||
|
||||
const updateCheckHangs: ApiMock = { status: 200, hang: true }
|
||||
|
||||
const restartStartedAt = '2026-09-30T09:51:00'
|
||||
const restartFinishedAt = '2026-09-30T09:52:00'
|
||||
const restartMemoryKey = 'renewcms:update-restart'
|
||||
|
||||
function restartApi(status: unknown, restart: ApiMock): Record<string, ApiMock> {
|
||||
return { ...updateApi('owner', status), 'POST /v1/system/update/restart': restart }
|
||||
}
|
||||
|
||||
function restartAccepted(status: Record<string, unknown>): ApiMock {
|
||||
return { status: 202, body: { ...status, state: 'restarting', started_at: restartStartedAt } }
|
||||
}
|
||||
|
||||
async function openRestartDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Restart', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
}
|
||||
|
||||
function confirmRestart(label: string) {
|
||||
return async (page: Page) => {
|
||||
await openRestartDialog(page)
|
||||
await page.getByRole('button', { name: label, exact: true }).click()
|
||||
}
|
||||
}
|
||||
|
||||
async function rememberRestartAndReload(page: Page) {
|
||||
await page.evaluate(
|
||||
([key, startedAt]) =>
|
||||
sessionStorage.setItem(key!, JSON.stringify({ kind: 'update', started_at: startedAt })),
|
||||
[restartMemoryKey, restartStartedAt],
|
||||
)
|
||||
await page.reload()
|
||||
await page.getByText('Your site is back on v2.2.0-beta.').first().waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function updateApi(
|
||||
role: 'owner' | 'admin',
|
||||
status: unknown,
|
||||
@@ -2089,12 +2125,42 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) },
|
||||
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) },
|
||||
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
|
||||
'Restart: confirm': { path: '/settings', skip: 'pending' },
|
||||
'Restart: request failed': { path: '/settings', skip: 'pending' },
|
||||
'Restarting to install': { path: '/settings', skip: 'pending' },
|
||||
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
|
||||
'Update done': { path: '/settings', skip: 'pending' },
|
||||
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
|
||||
'Restart: confirm': {
|
||||
path: '/settings',
|
||||
api: restartApi(updateJob('ready'), { status: 500 }),
|
||||
steps: openRestartDialog,
|
||||
},
|
||||
'Restart: request failed': {
|
||||
path: '/settings',
|
||||
api: restartApi(updateJob('ready'), { status: 500 }),
|
||||
steps: async (page) => {
|
||||
await confirmRestart('Restart now')(page)
|
||||
await page.getByText("Couldn't start the restart").waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Restarting to install': {
|
||||
path: '/settings',
|
||||
api: restartApi(updateJob('ready'), restartAccepted(updateJob('ready'))),
|
||||
steps: async (page) => {
|
||||
await confirmRestart('Restart now')(page)
|
||||
await page.getByText('Installing version 2.3.0').waitFor()
|
||||
},
|
||||
},
|
||||
'Restarting, taking longer': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Update done': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Update failed, rolled back': {
|
||||
path: '/settings',
|
||||
api: updateApi(
|
||||
'owner',
|
||||
updateJob('failed', {
|
||||
error: 'rolled_back',
|
||||
started_at: restartStartedAt,
|
||||
finished_at: restartFinishedAt,
|
||||
}),
|
||||
),
|
||||
steps: rememberRestartAndReload,
|
||||
},
|
||||
'Download failed': {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateJob('failed', { error: 'download' })),
|
||||
@@ -2123,9 +2189,23 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Plain restart: confirm': { path: '/settings', skip: 'pending' },
|
||||
'Plain restart: restarting': { path: '/settings', skip: 'pending' },
|
||||
'Plain restart: done': { path: '/settings', skip: 'pending' },
|
||||
'Plain restart: confirm': {
|
||||
path: '/settings',
|
||||
api: restartApi(updateStatusBody({ can_apply: true }), { status: 500 }),
|
||||
steps: openRestartDialog,
|
||||
},
|
||||
'Plain restart: restarting': {
|
||||
path: '/settings',
|
||||
api: restartApi(
|
||||
updateStatusBody({ can_apply: true }),
|
||||
restartAccepted(updateStatusBody({ can_apply: true })),
|
||||
),
|
||||
steps: async (page) => {
|
||||
await confirmRestart('Restart site')(page)
|
||||
await page.getByText('Restarting your site').waitFor()
|
||||
},
|
||||
},
|
||||
'Plain restart: done': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Admin view': {
|
||||
path: '/settings',
|
||||
api: updateApi('admin', updateAvailable()),
|
||||
|
||||
Reference in new issue
Block a user