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
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user