feat: restart to apply update, privileged helper, rollback

This commit is contained in:
fsylvain committed 2026-10-01 21:36:40 +02:00
1 parent 70460e4170
commit 54e8fd5fed
63 files changed
+8635 -164

No files matched your search

+104
View File
@@ -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>
+149 -77
View File
@@ -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>
+36 -2
View File
@@ -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)
})
})
+3
View File
@@ -0,0 +1,3 @@
export function reloadPage() {
window.location.reload()
}
+47
View File
@@ -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()
}
+36 -2
View File
@@ -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
View File
@@ -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,
+128 -6
View File
@@ -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() },
+39
View File
@@ -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
View File
@@ -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()),