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:
parent
11f8681feb
commit
bb25b3b98d
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class ApiRequestLog extends Model
|
|||
'status',
|
||||
'request_context',
|
||||
'response_summary',
|
||||
'response_body',
|
||||
'error_message',
|
||||
'duration_ms',
|
||||
'sync_log_id',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue