pp-planer/resources/js/Pages/ApiLogs/Index.vue
Thorsten Bus af0c72ebcc feat(ui): improve arrangement configurator, song preview, and downloads
Simplify ArrangementConfigurator: replace color pickers with compact
pills, add click-to-add from pool, use watcher-based auto-select for
new/cloned arrangements, remove group_colors from save payload.

Enhance SongsBlock preview: color-coded group headers with tinted
backgrounds, PDF download button inside preview modal, .pro download
link per matched song, show DB ccli_id with fallback to CTS ccli_id.

Fix Modal z-index for nested dialogs. Fix SlideUploader duplicate
upload on watch by adding deep option and upload guard. Expand API
log detail sections by default and increase JSON tree depth. Convert
song download button from emit to direct .pro download link.
2026-03-02 23:02:51 +01:00

257 lines
13 KiB
Vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import JsonTreeViewer from '@/Components/JsonTreeViewer.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)
function parseJson(str) {
if (!str) return null
if (typeof str === 'object') return str
try { return JSON.parse(str) }
catch { return str }
}
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) {
const data = await response.json()
data._showContext = true
data._showResponse = true
detailData.value[id] = data
}
} 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>
<div v-if="detailData[log.id]._showContext && detailData[log.id].request_context" class="mt-1 max-h-96 overflow-y-auto rounded-lg bg-gray-50 p-3">
<JsonTreeViewer :data="detailData[log.id].request_context" :expand-depth="2" />
</div>
<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>
<div v-if="detailData[log.id]._showResponse && detailData[log.id].response_body" class="mt-1 max-h-96 overflow-y-auto rounded-lg bg-gray-50 p-3">
<JsonTreeViewer :data="parseJson(detailData[log.id].response_body)" :expand-depth="2" />
</div>
<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>