pp-planer/resources/js/Pages/ApiLogs/Index.vue
Thorsten Bus bb25b3b98d feat(logs): store and lazy-load actual API response body in request log
- Add response_body longText column to api_request_logs table
- Store serialized response (max 500KB) in ChurchToolsService::logApiCall
- Add GET /api-logs/{log}/response-body endpoint for lazy loading
- Replace static 'Array mit X Einträgen' with collapsible JSON viewer
- Response body fetched on-demand when user clicks to expand
2026-03-02 13:25:45 +01:00

242 lines
13 KiB
Vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
const props = defineProps({
logs: {
type: Object,
required: true,
},
filters: {
type: Object,
default: () => ({ search: '', status: '' }),
},
})
const search = ref(props.filters.search ?? '')
const status = ref(props.filters.status ?? '')
let searchTimeout = null
function loadLogs(page = 1) {
router.get(route('api-logs.index'), {
search: search.value || undefined,
status: status.value || undefined,
page,
}, {
preserveState: true,
preserveScroll: true,
replace: true,
})
}
watch(status, () => loadLogs(1))
watch(search, () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => loadLogs(1), 300)
})
function formatDateTime(value) {
if (!value) return '—'
return new Date(value).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function statusBadgeClass(logStatus) {
if (logStatus === 'error') {
return 'bg-red-100 text-red-700 ring-red-200'
}
return 'bg-emerald-100 text-emerald-700 ring-emerald-200'
}
function statusText(logStatus) {
return logStatus === 'error' ? 'Fehler' : 'Erfolg'
}
const expandedId = ref(null)
const detailData = ref({})
const loadingDetail = ref(null)
async function toggleExpanded(id) {
if (expandedId.value === id) {
expandedId.value = null
return
}
expandedId.value = id
if (!detailData.value[id]) {
loadingDetail.value = id
try {
const response = await fetch(route('api-logs.response-body', id), {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
if (response.ok) {
detailData.value[id] = await response.json()
}
} catch (e) {
console.error('Detail load error:', e)
} finally {
loadingDetail.value = null
}
}
}
</script>
<template>
<Head title="API-Log" />
<AuthenticatedLayout>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-gray-900">API-Log</h1>
<p class="mt-1 text-sm text-gray-500">Hier siehst du alle CTS-API-Aufrufe mit Status und Laufzeit.</p>
</div>
</div>
</template>
<div class="py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mb-4 grid gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm sm:grid-cols-2">
<label class="block">
<span class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Suche</span>
<input
v-model="search"
data-testid="api-log-search"
type="search"
placeholder="Methode, Endpunkt oder Fehlertext"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-800 shadow-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-200"
>
</label>
<label class="block">
<span class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<select
v-model="status"
data-testid="api-log-status-filter"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-800 shadow-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-200"
>
<option value="">Alle</option>
<option value="success">Erfolg</option>
<option value="error">Fehler</option>
</select>
</label>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="overflow-x-auto">
<table data-testid="api-log-table" class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Zeitpunkt</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Methode</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Endpunkt</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Status</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Dauer (ms)</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Fehler</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<template v-for="log in logs.data" :key="log.id">
<tr
:class="[log.status === 'error' ? 'bg-red-50/70' : '', 'cursor-pointer hover:bg-gray-50 transition-colors']"
data-testid="api-log-row"
@click="toggleExpanded(log.id)"
>
<td class="px-4 py-3 text-sm text-gray-700">{{ formatDateTime(log.created_at) }}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ log.method }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ log.endpoint }}</td>
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset" :class="statusBadgeClass(log.status)">
{{ statusText(log.status) }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ log.duration_ms }}</td>
<td class="px-4 py-3 text-sm text-red-700">{{ log.error_message || '—' }}</td>
</tr>
<tr v-if="expandedId === log.id" class="bg-gray-50/50" data-testid="api-log-detail">
<td colspan="6" class="px-4 py-4">
<div v-if="loadingDetail === log.id" class="flex items-center gap-2 text-sm text-gray-500">
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-amber-400 border-t-transparent" />
Lade Details…
</div>
<div v-else-if="detailData[log.id]" class="space-y-3">
<div>
<button
class="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-700"
@click="detailData[log.id]._showContext = !detailData[log.id]._showContext"
>
<svg class="h-3 w-3 transition-transform" :class="{ 'rotate-90': detailData[log.id]._showContext }" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
Anfrage-Kontext
</button>
<pre v-if="detailData[log.id]._showContext && detailData[log.id].request_context" class="mt-1 max-h-96 overflow-x-auto overflow-y-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800">{{ JSON.stringify(detailData[log.id].request_context, null, 2) }}</pre>
<p v-if="detailData[log.id]._showContext && !detailData[log.id].request_context" class="mt-1 text-sm italic text-gray-400">Kein Kontext verfügbar</p>
</div>
<div>
<button
class="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-700"
@click="detailData[log.id]._showResponse = !detailData[log.id]._showResponse"
>
<svg class="h-3 w-3 transition-transform" :class="{ 'rotate-90': detailData[log.id]._showResponse }" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
Antwort-Daten
</button>
<pre v-if="detailData[log.id]._showResponse && detailData[log.id].response_body" class="mt-1 max-h-96 overflow-x-auto overflow-y-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800">{{ detailData[log.id].response_body }}</pre>
<p v-if="detailData[log.id]._showResponse && !detailData[log.id].response_body" class="mt-1 text-sm italic text-gray-400">Keine Antwort-Daten verfügbar</p>
</div>
</div>
<div v-else class="text-sm italic text-gray-400">Keine Details verfügbar</div>
</td>
</tr>
</template>
<tr v-if="logs.data.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">Keine API-Logs für deinen Filter gefunden.</td>
</tr>
</tbody>
</table>
</div>
<div v-if="logs.links?.length > 3" class="flex flex-wrap items-center gap-1 border-t border-gray-200 px-4 py-3">
<template v-for="link in logs.links" :key="`${link.label}-${link.url}`">
<span
v-if="!link.url"
class="rounded-md border border-gray-200 px-3 py-1.5 text-xs text-gray-400"
v-html="link.label"
/>
<Link
v-else
:href="link.url"
preserve-state
preserve-scroll
class="rounded-md border px-3 py-1.5 text-xs transition"
:class="link.active ? 'border-amber-500 bg-amber-50 font-semibold text-amber-700' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'"
v-html="link.label"
/>
</template>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>