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(null) const loadState = ref('idle') const phase = ref('idle') const applyPhase = ref('idle') const retryAfter = ref(0) const dismissedVersion = ref(readDismissedVersion()) let pendingLoad: Promise | 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(null) const restartSlow = ref(false) const restartNow = ref(Date.now()) let restartTimer: ReturnType | 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 { 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 = { 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> = { limited: 'limited', untrusted: 'untrusted', } const result = outcome ?? 'failed' applyPhase.value = phases[result] ?? (result === 'failed' ? 'failed' : 'idle') return result } async function check(): Promise { 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 { 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 = { 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, } })