- 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
242 lines
13 KiB
Vue
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>
|