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

+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>