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[] }; } export type UpdateLogs = { requestId: string; logs: { source: 'service' | 'events' | 'commands'; content: string; truncated: boolean }[] } type Overview = { acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob; installed: { version?: string }; backend: { version: string; revision: string }; capabilities?: { requestLogs: boolean }; } type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } } export const useSelfhostUpdates = () => { const { $api } = useNuxtApp() const auth = useAuthStore() const overview = ref(null) const job = ref(null) const pending = ref(null) const connected = ref(false) const submitting = ref(false) const refreshing = ref(false) const message = ref('') const accessDenied = ref(false) const logs = ref(null) const logsOpen = ref(false) const logsLoading = ref(false) const logsMessage = ref('') const logEntries = computed(() => logs.value?.requestId === job.value?.requestId ? logs.value?.logs || [] : []) let openedFailureId: string | undefined let stopped = false let generation = 0 let timer: ReturnType | 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 = (path: string, options = {}) => $api(`/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 refreshLogs = async () => { const id = job.value?.requestId if (!logsOpen.value || !id || stopped || accessDenied.value || logsLoading.value) return if (logs.value?.requestId !== id) { logs.value = null; logsMessage.value = '' } if (!overview.value?.capabilities?.requestLogs) { logsMessage.value = 'Dieser Host-Update-Dienst unterstützt die Protokollansicht noch nicht. Die zusammengehörigen Host-Update-Dateien aktualisieren und den Dienstinstaller erneut ausführen. Bis dahin liegen die Protokolle auf dem Host unter .updates/.' return } const currentGeneration = generation logsLoading.value = true try { const result = await api(`requests/${encodeURIComponent(id)}/logs`) if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return logs.value = result logsMessage.value = '' } catch (error: any) { if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return logsMessage.value = error?.data?.error?.message || 'Das Update-Protokoll konnte nicht geladen werden. Die Abfrage wird automatisch wiederholt.' if ([401, 403].includes(errorStatus(error))) { logs.value = null; report(error) } } finally { logsLoading.value = false } } const toggleLogs = async () => { logsOpen.value = !logsOpen.value; await refreshLogs() } const refresh = async () => { if (refreshing.value || stopped || accessDenied.value) return refreshing.value = true const currentGeneration = generation try { const latest = await api('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(`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() if (['failed', 'interrupted'].includes(job.value?.status || '') && openedFailureId !== job.value?.requestId) { openedFailureId = job.value?.requestId logsOpen.value = true } await refreshLogs() } 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++ logs.value = null logsMessage.value = '' submitting.value = true const submission = pending.value try { job.value = await api(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() } await refreshLogs() } 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, logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending } }