feat(selfhost): start full updates from administration
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 5m49s

This commit is contained in:
2026-09-23 18:49:10 +02:00
parent 60553bbba8
commit 8e1505023b
17 changed files with 672 additions and 27 deletions

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending } = useSelfhostUpdates()
const build = useRuntimeConfig().public
const confirmOpen = ref(false)
const confirmed = ref(false)
const phaseLabels: Record<string, string> = {
preparing: 'Update vorbereiten', validating: 'Release und Konfiguration prüfen', checked: 'Prüfung abgeschlossen',
pulling: 'Images herunterladen', maintenance: 'Dienste anhalten und Backup erstellen', backed_up: 'Backup abgeschlossen',
applying: 'Compose und Konfiguration aktualisieren', retiring_matrix: 'Alte Matrix-Dienste stilllegen',
migrating: 'Datenbank migrieren', starting: 'Dienste starten', verifying: 'Version und Erreichbarkeit prüfen',
complete: 'Abgeschlossen', resolved: 'Wiederherstellung bestätigt',
}
const statusLabels: Record<string, string> = {
queued: 'Auftrag angenommen', running: 'Auftrag läuft', checked: 'Update verfügbar', up_to_date: 'FEDEO ist aktuell',
succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen',
idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen',
}
const progress = computed(() => job.value?.progress || (busy.value ? overview.value?.engine : null))
const statusLabel = computed(() => phaseLabels[progress.value?.phase || ''] || statusLabels[job.value?.status || 'idle'])
const backupSize = computed(() => {
const bytes = job.value?.plan?.estimatedBackupBytes
return bytes === undefined ? 'Wird vor dem Start geprüft' : `${(bytes / 1024 ** 3).toLocaleString('de-DE', { maximumFractionDigits: 1 })} GiB`
})
const openConfirmation = () => { confirmed.value = false; confirmOpen.value = true }
const start = async () => {
if (!confirmed.value || !canApply.value) return
confirmOpen.value = false
await apply()
}
const reload = () => window.location.reload()
</script>
<template>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-arrow-down-tray" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">FEDEO-Updates</h2>
</div>
<UBadge color="neutral" variant="soft">Installiert: {{ overview?.backend.version || build.fedeoVersion }}</UBadge>
</div>
</template>
<div class="space-y-4">
<p class="text-sm text-muted">Aktualisiert die gesamte Selfhost-Instanz einschließlich Docker Compose und Datenbank. Vor Änderungen wird ein Backup erstellt.</p>
<div role="status" aria-live="polite" class="space-y-3">
<UAlert v-if="message" :color="busy ? 'warning' : 'neutral'" variant="soft" :title="message" />
<UAlert v-if="recovery" color="error" title="Wiederherstellung auf dem Host erforderlich" description="Weitere Updates sind gesperrt. Den letzten Auftrag und die Backups auf dem Host prüfen." />
<UAlert v-if="job?.error" color="error" :title="job.error.message" />
<UAlert v-if="job?.plan?.requiresComposeAdoption" color="warning" title="Compose-Datei zuerst auf dem Host übernehmen" description="Die bestehende Konfiguration benötigt eine manuelle Prüfung mit dem CLI-Updater. Anschließend hier erneut nach Updates suchen." />
<p v-if="job || busy" class="text-sm font-medium">{{ statusLabel }}</p>
<UProgress v-if="busy && !pending" aria-label="Update-Fortschritt" />
</div>
<dl v-if="job?.plan" class="grid gap-3 text-sm sm:grid-cols-3">
<div><dt class="text-muted">Geprüfte Version</dt><dd class="font-medium">{{ job.plan.version }}</dd></div>
<div><dt class="text-muted">Commit</dt><dd class="font-mono" :title="job.plan.revision">{{ job.plan.revision?.slice(0, 8) }}</dd></div>
<div><dt class="text-muted">Geschätztes Backup</dt><dd>{{ backupSize }}</dd></div>
</dl>
<p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p>
<p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p>
<div class="flex flex-wrap gap-2">
<UButton icon="i-heroicons-magnifying-glass" :disabled="!available" :loading="submitting && pending?.command === 'check'" @click="check">Nach Updates suchen</UButton>
<UButton v-if="canApply" icon="i-heroicons-arrow-down-tray" @click="openConfirmation">Update auf {{ job?.plan?.version }} installieren</UButton>
<UButton v-if="pending && connected && !accessDenied" color="warning" :loading="submitting" @click="sendPending">Auftrag erneut senden</UButton>
<UButton v-if="!connected && !accessDenied" color="neutral" variant="outline" :loading="refreshing" @click="refresh">Verbindung prüfen</UButton>
<UButton v-if="job?.command === 'apply' && job.status === 'succeeded'" color="success" @click="reload">Neue Version laden</UButton>
</div>
</div>
</UCard>
<UModal v-model:open="confirmOpen" title="FEDEO-Update installieren" description="Dieses Update betrifft die gesamte Instanz und alle Mandanten.">
<template #body>
<div class="space-y-4 text-sm">
<p>Version <strong>{{ job?.plan?.version }}</strong> wird installiert. Dabei werden die Dienste vorübergehend angehalten, Daten gesichert und Datenbankmigrationen ausgeführt.</p>
<p>Die Verbindung kann währenddessen abbrechen. Der Host setzt den Auftrag fort; diese Seite fragt den Status automatisch wieder ab.</p>
<p>Falls die Migration fehlschlägt, kann eine manuelle Wiederherstellung auf dem Host erforderlich sein.</p>
<UCheckbox v-model="confirmed" label="Die Wartungsunterbrechung für alle Mandanten ist abgestimmt." />
</div>
</template>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<UButton color="neutral" variant="outline" @click="confirmOpen = false">Abbrechen</UButton>
<UButton :disabled="!confirmed || !canApply" :loading="submitting" @click="start">Jetzt installieren</UButton>
</div>
</template>
</UModal>
</template>

View 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 }
}

View File

@@ -96,6 +96,7 @@ onMounted(async () => {
<UDashboardPanelContent>
<div class="space-y-6">
<SelfhostUpdateCard v-if="auth.user?.is_admin" />
<UAlert
:icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:color="overallStatus === 'ok' ? 'success' : 'warning'"

View File

@@ -0,0 +1,101 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const check = { status: 'checked', command: 'check', requestId: 'checked-request', plan: {
version: '3.2.0', revision: 'a'.repeat(40), updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
} }
const overview = (job = check, engine = { status: 'checked' }) => ({ acceptingRequests: true, engine, request: job, installed: {}, backend: { version: '3.1.0' } })
async function setup(t, api, saved = null) {
const mounted = [], unmounted = [], calls = [], storage = new Map(saved ? [['fedeo:update:admin', saved]] : [])
let nextId = 0
const context = vm.createContext({
ref: vue.ref, computed: vue.computed, onMounted: fn => mounted.push(fn), onBeforeUnmount: fn => unmounted.push(fn),
useAuthStore: () => ({ user: { id: 'admin' } }),
useNuxtApp: () => ({ $api: async (path, options) => { calls.push({ path, options }); return api(path, options) } }),
localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) },
crypto: { randomUUID: () => `request-id-${++nextId}` }, setTimeout: () => 1, clearTimeout() {},
})
const source = fs.readFileSync(__dirname + '/../composables/useSelfhostUpdates.ts', 'utf8').replace(/export /g, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }) + '\nglobalThis.ui = useSelfhostUpdates()', context)
mounted.forEach(fn => fn()); await new Promise(setImmediate)
t.after(() => unmounted.forEach(fn => fn()))
return { ui: context.ui, calls, storage }
}
test('checked version enables one apply; duplicate click is blocked and polling survives restart', async t => {
let state = overview(), offline = false, finish
const f = await setup(t, async (path, options) => {
if (offline) throw new Error('network')
if (options.method === 'POST') return new Promise(resolve => { finish = resolve })
return path.endsWith('/status') ? state : state.request
})
assert.equal(f.ui.canApply.value, true)
const start = f.ui.apply(); await f.ui.apply()
assert.equal(f.calls.filter(c => c.options.method === 'POST').length, 1)
const body = f.calls.at(-1).options.body
assert.equal(body.version, '3.2.0'); assert.equal(body.checkRequestId, 'checked-request')
state = overview({ status: 'running', command: 'apply', requestId: body.requestId }, { status: 'running', recoveryRequired: true })
finish(state.request); await start
await f.ui.refresh(); assert.equal(f.ui.recovery.value, false)
offline = true; await f.ui.refresh()
assert.match(f.ui.message.value, /Host führt das Update weiter/)
assert.equal(f.ui.canApply.value, false)
offline = false; state = overview({ ...state.request, status: 'succeeded' }, { status: 'succeeded' })
await f.ui.refresh(); assert.equal(f.ui.job.value.status, 'succeeded')
})
test('lost start response is recovered after reload without replaying an update', async t => {
const first = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
return path.endsWith('/status') ? overview() : check
})
await first.ui.apply()
const id = first.ui.pending.value.body.requestId
const saved = first.storage.get('fedeo:update:admin')
const running = { status: 'running', command: 'apply', requestId: id }
const second = await setup(t, async path => path.endsWith('/status') ? overview(running) : running, saved)
assert.equal(second.ui.pending.value, null)
assert.equal(second.ui.job.value.requestId, id)
assert.equal(second.calls.some(c => c.options.method === 'POST'), false)
})
test('unconfirmed submission retains its original ID for explicit retry', async t => {
const saved = JSON.stringify({ pending: { command: 'apply', body: { requestId: 'original-request', version: '3.2.0', checkRequestId: 'checked-request' } } })
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', command: 'apply', requestId: options.body.requestId }
if (path.endsWith('/status')) return overview()
throw { statusCode: 404 }
}, saved)
assert.equal(f.ui.canApply.value, false)
await f.ui.sendPending()
assert.equal(f.calls.at(-1).options.body.requestId, 'original-request')
})
test('recovery, up-to-date, unmanaged compose and other active jobs prevent applying', async t => {
for (const state of [overview(check, { status: 'failed', recoveryRequired: true }),
overview({ ...check, status: 'up_to_date' }),
overview({ ...check, plan: { ...check.plan, requiresComposeAdoption: true } }),
overview({ status: 'running', command: 'apply', requestId: 'someone-else' })]) {
const f = await setup(t, async () => state)
assert.equal(f.ui.canApply.value, false)
await f.ui.apply(); assert.equal(f.calls.some(c => c.options.method === 'POST'), false)
}
})
test('an older polling response cannot erase a new submission whose response was lost', async t => {
let hold = false, release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
if (path.endsWith('/status')) return overview()
if (hold) return new Promise(resolve => { release = resolve })
return check
})
hold = true
const polling = f.ui.refresh()
await new Promise(setImmediate)
await f.ui.apply()
const id = f.ui.pending.value.body.requestId
release(check); await polling
assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false)
})