feat(selfhost): start full updates from administration
This commit is contained in:
87
frontend/components/SelfhostUpdateCard.vue
Normal file
87
frontend/components/SelfhostUpdateCard.vue
Normal 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>
|
||||
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 }
|
||||
}
|
||||
@@ -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'"
|
||||
|
||||
101
frontend/tests/selfhost-updates.test.cjs
Normal file
101
frontend/tests/selfhost-updates.test.cjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user