feat(selfhost): start full updates from administration
This commit is contained in:
149
frontend/composables/useSelfhostUpdates.ts
Normal file
149
frontend/composables/useSelfhostUpdates.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export type UpdateState = {
|
||||
status: string; requestId?: string; command?: 'check' | 'apply'; phase?: string;
|
||||
version?: string; revision?: string; recoveryRequired?: boolean; updatedAt?: string;
|
||||
}
|
||||
export type UpdateJob = UpdateState & {
|
||||
progress?: UpdateState; error?: { code: string; message: string };
|
||||
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean;
|
||||
requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] };
|
||||
}
|
||||
type Overview = {
|
||||
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
|
||||
installed: { version?: string }; backend: { version: string; revision: string };
|
||||
}
|
||||
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }
|
||||
|
||||
export const useSelfhostUpdates = () => {
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
const overview = ref<Overview | null>(null)
|
||||
const job = ref<UpdateJob | null>(null)
|
||||
const pending = ref<Submission | null>(null)
|
||||
const connected = ref(false)
|
||||
const submitting = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const message = ref('')
|
||||
const accessDenied = ref(false)
|
||||
let stopped = false
|
||||
let generation = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const storageKey = `fedeo:update:${auth.user?.user_id || auth.user?.id || auth.user?.email}`
|
||||
const active = (value?: UpdateState | null) => ['queued', 'running'].includes(value?.status || '')
|
||||
const recovery = computed(() => !!overview.value?.engine.recoveryRequired && !active(overview.value?.engine))
|
||||
const busy = computed(() => submitting.value || !!pending.value || active(job.value) || active(overview.value?.engine))
|
||||
const available = computed(() => connected.value && !accessDenied.value && overview.value?.acceptingRequests && !busy.value && !recovery.value)
|
||||
const canApply = computed(() => !!(available.value && job.value?.command === 'check' && job.value.status === 'checked'
|
||||
&& job.value.plan?.updateAvailable && !job.value.plan.requiresComposeAdoption))
|
||||
|
||||
const persist = () => {
|
||||
// Losing localStorage must not lose the durable host job; status also returns the latest request.
|
||||
try { localStorage.setItem(storageKey, JSON.stringify({ requestId: job.value?.requestId, pending: pending.value })) } catch {}
|
||||
}
|
||||
const api = <T>(path: string, options = {}) => $api<T>(`/api/admin/updates/${path}`, {
|
||||
retry: 0, timeout: 8000, onRequestError: () => {}, onResponseError: () => {}, ...options,
|
||||
})
|
||||
const errorStatus = (error: any) => error?.statusCode || error?.response?.status
|
||||
const report = (error: any) => {
|
||||
if ([401, 403].includes(errorStatus(error))) {
|
||||
accessDenied.value = true
|
||||
message.value = 'Bitte erneut als Systemadministrator anmelden.'
|
||||
} else if (pending.value) {
|
||||
message.value = 'Der Start ist noch nicht bestätigt. Der Auftragsstatus wird weiter geprüft.'
|
||||
} else if (job.value?.command === 'apply' && active(job.value)) {
|
||||
message.value = 'Verbindung unterbrochen. Der Host führt das Update weiter aus; die Verbindung wird automatisch erneut geprüft.'
|
||||
} else {
|
||||
message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.'
|
||||
}
|
||||
}
|
||||
const refresh = async () => {
|
||||
if (refreshing.value || stopped || accessDenied.value) return
|
||||
refreshing.value = true
|
||||
const currentGeneration = generation
|
||||
try {
|
||||
const latest = await api<Overview>('status')
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
overview.value = latest
|
||||
// A durable submission may have succeeded even when its HTTP response was lost.
|
||||
const ownId = pending.value?.body.requestId || job.value?.requestId
|
||||
if (ownId) {
|
||||
try {
|
||||
const result = await api<UpdateJob>(`requests/${encodeURIComponent(ownId)}`)
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
job.value = result
|
||||
pending.value = null
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
if (errorStatus(error) !== 404) throw error
|
||||
if (!pending.value) job.value = null
|
||||
}
|
||||
}
|
||||
if (!pending.value && latest.request && (active(latest.request) || !job.value)) job.value = latest.request
|
||||
connected.value = true
|
||||
message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : ''
|
||||
persist()
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
connected.value = false
|
||||
report(error)
|
||||
} finally { refreshing.value = false }
|
||||
}
|
||||
const sendPending = async () => {
|
||||
if (!pending.value || submitting.value || accessDenied.value) return
|
||||
generation++
|
||||
submitting.value = true
|
||||
const submission = pending.value
|
||||
try {
|
||||
job.value = await api<UpdateJob>(submission.command, { method: 'POST', body: submission.body })
|
||||
pending.value = null
|
||||
connected.value = true
|
||||
message.value = ''
|
||||
} catch (error: any) {
|
||||
// Only a definite rejection releases the ID. Timeouts/5xx keep it for idempotent recovery.
|
||||
if ([400, 401, 403, 404, 409, 413, 415].includes(errorStatus(error))) {
|
||||
pending.value = null
|
||||
job.value = null
|
||||
message.value = error?.data?.error?.message || 'Der Auftrag wurde abgelehnt. Bitte erneut prüfen.'
|
||||
if ([401, 403].includes(errorStatus(error))) report(error)
|
||||
} else {
|
||||
connected.value = false
|
||||
report(error)
|
||||
}
|
||||
} finally { submitting.value = false; persist() }
|
||||
}
|
||||
const check = async () => {
|
||||
if (!available.value) return
|
||||
pending.value = { command: 'check', body: { requestId: crypto.randomUUID() } }
|
||||
job.value = null
|
||||
persist()
|
||||
await sendPending()
|
||||
}
|
||||
const apply = async () => {
|
||||
if (!canApply.value) return
|
||||
pending.value = { command: 'apply', body: {
|
||||
requestId: crypto.randomUUID(), version: job.value!.plan!.version, checkRequestId: job.value!.requestId!,
|
||||
} }
|
||||
persist()
|
||||
await sendPending()
|
||||
}
|
||||
const poll = async () => {
|
||||
await refresh()
|
||||
if (!stopped && !accessDenied.value) timer = setTimeout(poll, busy.value || !connected.value ? 3000 : 15000)
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null')
|
||||
const validId = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(value)
|
||||
if (validId(saved?.requestId)) job.value = { status: 'unknown', requestId: saved.requestId }
|
||||
const p = saved?.pending
|
||||
if (p && ['check', 'apply'].includes(p.command) && validId(p.body?.requestId)
|
||||
&& (p.command === 'check' || (validId(p.body?.checkRequestId) && /^\d+\.\d+\.\d+$/.test(p.body?.version)))) {
|
||||
pending.value = { command: p.command, body: { requestId: p.body.requestId,
|
||||
...(p.command === 'apply' ? { version: p.body.version, checkRequestId: p.body.checkRequestId } : {}) } }
|
||||
}
|
||||
} catch {}
|
||||
void poll()
|
||||
})
|
||||
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
|
||||
return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
|
||||
check, apply, refresh, sendPending }
|
||||
}
|
||||
Reference in New Issue
Block a user