333 lines
8.8 KiB
TypeScript
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,
|
|
}
|
|
})
|