feat(selfhost): show sanitized update logs in admin UI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 5m45s

This commit is contained in:
2026-09-25 18:42:26 +02:00
parent 8f06d06037
commit 29155f916a
9 changed files with 398 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending } = useSelfhostUpdates()
logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending } = useSelfhostUpdates()
const build = useRuntimeConfig().public
const confirmOpen = ref(false)
const confirmed = ref(false)
@@ -16,6 +16,7 @@ const statusLabels: Record<string, string> = {
succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen',
idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen',
}
const logLabels = { service: 'Fehlerdetails', events: 'Update-Verlauf', commands: 'Werkzeugausgaben' }
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(() => {
@@ -59,6 +60,24 @@ const reload = () => window.location.reload()
</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>
<section v-if="job?.requestId && !accessDenied" aria-label="Update-Protokoll" class="space-y-3 rounded-lg border border-default p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<UButton color="neutral" variant="ghost" icon="i-heroicons-document-text" :aria-expanded="logsOpen" @click="toggleLogs">
{{ logsOpen ? 'Update-Protokoll ausblenden' : 'Update-Protokoll anzeigen' }}
</UButton>
<UButton v-if="logsOpen && overview?.capabilities?.requestLogs" color="neutral" variant="outline" size="xs" :loading="logsLoading" :disabled="!connected" @click="refreshLogs">Protokoll aktualisieren</UButton>
</div>
<div v-if="logsOpen" class="space-y-3">
<p class="text-xs text-muted">Die letzten Protokolleinträge werden automatisch aktualisiert. Erkannte Zugangsdaten sind ausgeblendet.</p>
<UAlert v-if="logsMessage" color="warning" variant="soft" :title="logsMessage" />
<p v-else-if="!logEntries.length" class="text-sm text-muted">{{ logsLoading ? 'Protokoll wird geladen …' : 'Für diesen Auftrag liegen noch keine Protokolleinträge vor.' }}</p>
<div v-for="entry in logEntries" :key="entry.source" class="space-y-1">
<h3 class="text-sm font-medium">{{ logLabels[entry.source] }}</h3>
<p v-if="entry.truncated" class="text-xs text-muted">Gekürzte Anzeige der letzten Einträge. Das vollständige Protokoll liegt auf dem Host.</p>
<pre tabindex="0" :aria-label="logLabels[entry.source]" class="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-elevated p-3 text-xs">{{ entry.content }}</pre>
</div>
</div>
</section>
<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>

View File

@@ -7,9 +7,11 @@ export type UpdateJob = UpdateState & {
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 } }
@@ -24,6 +26,12 @@ export const useSelfhostUpdates = () => {
const refreshing = ref(false)
const message = ref('')
const accessDenied = ref(false)
const logs = ref<UpdateLogs | null>(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<typeof setTimeout> | undefined
@@ -55,6 +63,28 @@ export const useSelfhostUpdates = () => {
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<UpdateLogs>(`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
@@ -81,6 +111,11 @@ export const useSelfhostUpdates = () => {
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
@@ -90,6 +125,8 @@ export const useSelfhostUpdates = () => {
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 {
@@ -109,6 +146,7 @@ export const useSelfhostUpdates = () => {
report(error)
}
} finally { submitting.value = false; persist() }
await refreshLogs()
}
const check = async () => {
if (!available.value) return
@@ -145,5 +183,5 @@ export const useSelfhostUpdates = () => {
})
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending }
logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending }
}

View File

@@ -99,3 +99,90 @@ test('an older polling response cannot erase a new submission whose response was
assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false)
})
test('failed jobs automatically show logs and refresh them only while open', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request', error: { code: 'UPDATE_FAILED', message: 'Auftrag fehlgeschlagen.' } }
let content = 'pull access denied'
const f = await setup(t, async path => {
if (path.endsWith('/logs')) return { requestId: failed.requestId, logs: [{ source: 'commands', content, truncated: false }] }
return path.endsWith('/status') ? { ...overview(failed), capabilities: { requestLogs: true } } : failed
})
assert.equal(f.ui.logsOpen.value, true)
assert.equal(f.ui.logEntries.value[0].content, content)
content = 'final failure detail'
await f.ui.refresh()
assert.equal(f.ui.logEntries.value[0].content, content)
await f.ui.toggleLogs()
const count = f.calls.filter(c => c.path.endsWith('/logs')).length
await f.ui.refresh()
assert.equal(f.ui.logsOpen.value, false)
assert.equal(f.calls.filter(c => c.path.endsWith('/logs')).length, count)
})
test('older host services explain the required upgrade without requesting unsupported endpoints', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request' }
const f = await setup(t, async path => path.endsWith('/status') ? overview(failed) : failed)
assert.equal(f.ui.logsOpen.value, true)
assert.match(f.ui.logsMessage.value, /Host-Update-Dateien aktualisieren/)
assert.equal(f.calls.some(c => c.path.endsWith('/logs')), false)
assert.equal(f.ui.connected.value, true)
})
test('log failures preserve job state and revoked admin access clears the displayed logs', async t => {
let failure
const f = await setup(t, async path => {
if (path.endsWith('/logs')) {
if (failure) throw failure
return { requestId: check.requestId, logs: [{ source: 'commands', content: 'diagnostic', truncated: false }] }
}
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
await f.ui.toggleLogs()
failure = { statusCode: 503 }
await f.ui.refreshLogs()
assert.match(f.ui.logsMessage.value, /konnte nicht geladen/)
assert.equal(f.ui.connected.value, true)
assert.equal(f.ui.job.value.status, 'checked')
failure = { statusCode: 403 }
await f.ui.refreshLogs()
assert.equal(f.ui.accessDenied.value, true)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('a delayed log response cannot appear under a newly submitted job', async t => {
let release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', requestId: options.body.requestId, command: 'check' }
if (path.endsWith('/logs')) return new Promise(resolve => { release = resolve })
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
const loading = f.ui.toggleLogs()
await new Promise(setImmediate)
await f.ui.check()
release({ requestId: check.requestId, logs: [{ source: 'commands', content: 'previous job', truncated: false }] })
await loading
assert.notEqual(f.ui.job.value.requestId, check.requestId)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('the update card renders log output as escaped text and explains truncation', async t => {
const f = await setup(t, async path => path.endsWith('/status') ? overview() : check)
f.ui.logsOpen.value = true
const { parse, compileScript } = require('@vue/compiler-sfc')
const descriptor = parse(fs.readFileSync(__dirname + '/../components/SelfhostUpdateCard.vue', 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: 'update-card-test', inlineTemplate: true }).content
const exports = {}
const context = vm.createContext({ exports, require, ref: vue.ref, computed: vue.computed,
useRuntimeConfig: () => ({ public: {} }), useSelfhostUpdates: () => ({ ...f.ui,
logEntries: vue.ref([{ source: 'commands', content: '<script>alert("log")</script>', truncated: true }]) }) })
vm.runInContext(ts.transpile(compiled, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
const app = vue.createSSRApp(exports.default)
for (const name of ['UCard', 'UIcon', 'UBadge', 'UAlert', 'UProgress', 'UButton', 'UModal', 'UCheckbox']) {
app.component(name, { setup: (_props, { slots }) => () => vue.h('div', slots.default?.()) })
}
const html = await require('@vue/server-renderer').renderToString(app)
assert.match(html, /Werkzeugausgaben/)
assert.match(html, /Gekürzte Anzeige/)
assert.match(html, /&lt;script&gt;/)
assert.doesNotMatch(html, /<script>/)
})