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