feat(selfhost): show sanitized update logs in admin UI
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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, /<script>/)
|
||||
assert.doesNotMatch(html, /<script>/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user