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

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