Files
RenewCMS/web/src/stores/update.ts
T

333 lines
8.8 KiB
TypeScript

import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
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'
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
export type UpdateCheckResult = 'updated' | 'failed' | 'limited' | 'forbidden' | 'busy'
export type UpdateApplyPhase = 'idle' | 'starting' | 'failed' | 'untrusted' | 'limited'
export type UpdateStartResult =
| 'started'
| 'failed'
| 'untrusted'
| 'limited'
| 'forbidden'
| 'nothing'
| '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 {
return localStorage.getItem(DISMISSED_VERSION_KEY)
} catch {
return null
}
}
function writeDismissedVersion(version: string) {
try {
localStorage.setItem(DISMISSED_VERSION_KEY, version)
} catch {
return
}
}
export const useUpdateStore = defineStore('update', () => {
const session = useSessionStore()
const status = ref<UpdateStatus | null>(null)
const loadState = ref<UpdateLoadState>('idle')
const phase = ref<UpdateCheckPhase>('idle')
const applyPhase = ref<UpdateApplyPhase>('idle')
const retryAfter = ref(0)
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
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 && !restarting.value && status.value?.latest !== dismissedVersion.value,
)
const jobRunning = computed(
() =>
isOwner.value &&
status.value?.can_apply === true &&
(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) {
return pendingLoad
}
const { getUpdateStatus } = useUpdate()
loadState.value = 'loading'
if (applyPhase.value !== 'starting') {
applyPhase.value = 'idle'
}
pendingLoad = Promise.resolve()
.then(getUpdateStatus)
.then((result) => {
status.value = result
loadState.value = 'ready'
})
.catch(() => {
loadState.value = status.value === null ? 'error' : 'ready'
})
.finally(() => {
pendingLoad = null
})
return pendingLoad
}
let pendingPoll = false
async function poll() {
if (pendingPoll) {
return
}
const { getUpdateStatus } = useUpdate()
pendingPoll = true
try {
status.value = await getUpdateStatus()
} catch {
return
} finally {
pendingPoll = false
}
}
async function startDownload(): Promise<UpdateStartResult> {
if (applyPhase.value === 'starting') {
return 'busy'
}
const { startUpdateDownload } = useUpdate()
applyPhase.value = 'starting'
try {
status.value = await startUpdateDownload()
loadState.value = 'ready'
applyPhase.value = 'idle'
return 'started'
} catch (error) {
return settleStartFailure(error)
}
}
function settleStartFailure(error: unknown): UpdateStartResult {
const outcomes: Record<number, UpdateStartResult> = {
400: 'nothing',
403: 'forbidden',
409: 'conflict',
422: 'untrusted',
429: 'limited',
}
const outcome = error instanceof ApiError ? outcomes[error.status] : undefined
if (outcome === 'limited' && error instanceof ApiError) {
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
}
const phases: Partial<Record<UpdateStartResult, UpdateApplyPhase>> = {
limited: 'limited',
untrusted: 'untrusted',
}
const result = outcome ?? 'failed'
applyPhase.value = phases[result] ?? (result === 'failed' ? 'failed' : 'idle')
return result
}
async function check(): Promise<UpdateCheckResult> {
if (phase.value === 'checking') {
return 'busy'
}
const { checkForUpdate } = useUpdate()
phase.value = 'checking'
try {
status.value = await checkForUpdate()
loadState.value = 'ready'
phase.value = 'idle'
return 'updated'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
phase.value = 'limited'
return 'limited'
}
if (error instanceof ApiError && error.status === 403) {
phase.value = 'idle'
return 'forbidden'
}
phase.value = 'failed'
return 'failed'
}
}
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
writeDismissedVersion(status.value.latest)
}
}
return {
status,
loadState,
phase,
applyPhase,
retryAfter,
isOwner,
available,
showCue,
bannerVisible,
jobRunning,
readyToInstall,
restarting,
restartRun,
restartSlow,
restartNow,
requestRestart,
resumeRestart,
stopRestart,
settleRememberedRestart,
load,
poll,
startDownload,
check,
dismissBanner,
}
})