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