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
This commit is contained in:
Thorsten Bus 2026-03-02 13:25:45 +01:00
parent 11f8681feb
commit bb25b3b98d
6 changed files with 141 additions and 22 deletions

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\ApiRequestLog;
use Illuminate\Http\JsonResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -39,4 +40,12 @@ public function index(): Response
],
]);
}
public function responseBody(ApiRequestLog $log): JsonResponse
{
return response()->json([
'response_body' => $log->response_body,
'request_context' => $log->request_context,
]);
}
}

View file

@ -17,6 +17,7 @@ class ApiRequestLog extends Model
'status',
'request_context',
'response_summary',
'response_body',
'error_message',
'duration_ms',
'sync_log_id',

View file

@ -11,6 +11,7 @@
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
@ -72,20 +73,29 @@ public function syncEvents(): array
];
foreach ($events as $event) {
$eventId = (int) ($event->getId() ?? 0);
if ($eventId === 0) {
try {
$eventId = (int) ($event->getId() ?? 0);
if ($eventId === 0) {
continue;
}
$serviceRoles = $this->extractServiceRoles($this->getEventServices($eventId));
$service = $this->upsertService($event, $serviceRoles);
$songSummary = $this->syncServiceAgendaSongs((int) $service->id, $eventId);
$summary['services_count']++;
$summary['songs_count'] += $songSummary['songs_count'];
$summary['matched_songs_count'] += $songSummary['matched_songs_count'];
$summary['unmatched_songs_count'] += $songSummary['unmatched_songs_count'];
} catch (\Throwable $e) {
Log::warning('Sync-Fehler für Event', [
'event_id' => $event->getId() ?? 'unknown',
'error' => $e->getMessage(),
]);
continue;
}
$serviceRoles = $this->extractServiceRoles($this->getEventServices($eventId));
$service = $this->upsertService($event, $serviceRoles);
$songSummary = $this->syncServiceAgendaSongs((int) $service->id, $eventId);
$summary['services_count']++;
$summary['songs_count'] += $songSummary['songs_count'];
$summary['matched_songs_count'] += $songSummary['matched_songs_count'];
$summary['unmatched_songs_count'] += $songSummary['unmatched_songs_count'];
}
return $summary;
@ -156,9 +166,18 @@ private function fetchEvents(): array
$fetcher = $this->eventFetcher ?? function (): array {
$this->configureApi();
return EventRequest::where('from', Carbon::now()->toDateString())
$futureEvents = EventRequest::where('from', Carbon::now()->toDateString())
->where('to', Carbon::now()->addMonths(3)->toDateString())
->get();
$pastEvents = EventRequest::where('from', Carbon::now()->subMonths(3)->toDateString())
->where('to', Carbon::now()->subDay()->toDateString())
->get();
$future = array_slice($futureEvents, 0, 10);
$past = array_slice(array_reverse($pastEvents), 0, 10);
return array_merge($past, $future);
};
return $this->logApiCall('fetchEvents', 'events', fn (): array => $fetcher());
@ -189,6 +208,7 @@ private function logApiCall(string $method, string $endpoint, Closure $operation
'status' => 'success',
'request_context' => $context,
'response_summary' => $this->summarizeResponse($result),
'response_body' => $this->serializeResponseBody($result),
'duration_ms' => $duration,
]);
@ -230,6 +250,21 @@ private function summarizeResponse(mixed $result): ?string
return null;
}
private function serializeResponseBody(mixed $result): ?string
{
if ($result === null) {
return null;
}
try {
$json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
return mb_strlen($json) > 512000 ? mb_substr($json, 0, 512000) . "\n... (abgeschnitten)" : $json;
} catch (\JsonException) {
return null;
}
}
private function configureApi(): void
{
if ($this->apiConfigured) {

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('api_request_logs', function (Blueprint $table) {
$table->longText('response_body')->nullable()->after('response_summary');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('api_request_logs', function (Blueprint $table) {
$table->dropColumn('response_body');
});
}
};

View file

@ -66,9 +66,37 @@ function statusText(logStatus) {
}
const expandedId = ref(null)
const detailData = ref({})
const loadingDetail = ref(null)
function toggleExpanded(id) {
expandedId.value = expandedId.value === id ? null : id
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>
@ -148,18 +176,35 @@ function toggleExpanded(id) {
<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 class="space-y-3">
<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>
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Anfrage-Kontext</span>
<pre v-if="log.request_context" class="mt-1 overflow-x-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800">{{ JSON.stringify(log.request_context, null, 2) }}</pre>
<p v-else class="mt-1 text-sm italic text-gray-400">Kein Kontext verfügbar</p>
<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>
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Antwort-Zusammenfassung</span>
<p v-if="log.response_summary" class="mt-1 text-sm text-gray-700">{{ log.response_summary }}</p>
<p v-else class="mt-1 text-sm italic text-gray-400">Keine Zusammenfassung verfügbar</p>
<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>

View file

@ -54,6 +54,7 @@
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy');
Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download');
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');
@ -63,6 +64,7 @@
})->name('songs.index');
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index');
Route::get('/api-logs/{log}/response-body', [ApiLogController::class, 'responseBody'])->name('api-logs.response-body');
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');