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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\ApiRequestLog;
|
use App\Models\ApiRequestLog;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
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',
|
'status',
|
||||||
'request_context',
|
'request_context',
|
||||||
'response_summary',
|
'response_summary',
|
||||||
|
'response_body',
|
||||||
'error_message',
|
'error_message',
|
||||||
'duration_ms',
|
'duration_ms',
|
||||||
'sync_log_id',
|
'sync_log_id',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
|
@ -72,6 +73,7 @@ public function syncEvents(): array
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($events as $event) {
|
foreach ($events as $event) {
|
||||||
|
try {
|
||||||
$eventId = (int) ($event->getId() ?? 0);
|
$eventId = (int) ($event->getId() ?? 0);
|
||||||
if ($eventId === 0) {
|
if ($eventId === 0) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -86,6 +88,14 @@ public function syncEvents(): array
|
||||||
$summary['songs_count'] += $songSummary['songs_count'];
|
$summary['songs_count'] += $songSummary['songs_count'];
|
||||||
$summary['matched_songs_count'] += $songSummary['matched_songs_count'];
|
$summary['matched_songs_count'] += $songSummary['matched_songs_count'];
|
||||||
$summary['unmatched_songs_count'] += $songSummary['unmatched_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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
|
|
@ -156,9 +166,18 @@ private function fetchEvents(): array
|
||||||
$fetcher = $this->eventFetcher ?? function (): array {
|
$fetcher = $this->eventFetcher ?? function (): array {
|
||||||
$this->configureApi();
|
$this->configureApi();
|
||||||
|
|
||||||
return EventRequest::where('from', Carbon::now()->toDateString())
|
$futureEvents = EventRequest::where('from', Carbon::now()->toDateString())
|
||||||
->where('to', Carbon::now()->addMonths(3)->toDateString())
|
->where('to', Carbon::now()->addMonths(3)->toDateString())
|
||||||
->get();
|
->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());
|
return $this->logApiCall('fetchEvents', 'events', fn (): array => $fetcher());
|
||||||
|
|
@ -189,6 +208,7 @@ private function logApiCall(string $method, string $endpoint, Closure $operation
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'request_context' => $context,
|
'request_context' => $context,
|
||||||
'response_summary' => $this->summarizeResponse($result),
|
'response_summary' => $this->summarizeResponse($result),
|
||||||
|
'response_body' => $this->serializeResponseBody($result),
|
||||||
'duration_ms' => $duration,
|
'duration_ms' => $duration,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -230,6 +250,21 @@ private function summarizeResponse(mixed $result): ?string
|
||||||
return null;
|
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
|
private function configureApi(): void
|
||||||
{
|
{
|
||||||
if ($this->apiConfigured) {
|
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 expandedId = ref(null)
|
||||||
|
const detailData = ref({})
|
||||||
|
const loadingDetail = ref(null)
|
||||||
|
|
||||||
function toggleExpanded(id) {
|
async function toggleExpanded(id) {
|
||||||
expandedId.value = expandedId.value === id ? null : 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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -148,18 +176,35 @@ function toggleExpanded(id) {
|
||||||
|
|
||||||
<tr v-if="expandedId === log.id" class="bg-gray-50/50" data-testid="api-log-detail">
|
<tr v-if="expandedId === log.id" class="bg-gray-50/50" data-testid="api-log-detail">
|
||||||
<td colspan="6" class="px-4 py-4">
|
<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>
|
<div>
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Anfrage-Kontext</span>
|
<button
|
||||||
<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>
|
class="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-700"
|
||||||
<p v-else class="mt-1 text-sm italic text-gray-400">Kein Kontext verfügbar</p>
|
@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>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Antwort-Zusammenfassung</span>
|
<button
|
||||||
<p v-if="log.response_summary" class="mt-1 text-sm text-gray-700">{{ log.response_summary }}</p>
|
class="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-700"
|
||||||
<p v-else class="mt-1 text-sm italic text-gray-400">Keine Zusammenfassung verfügbar</p>
|
@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>
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-400">Keine Details verfügbar</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
|
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
|
||||||
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
|
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
|
||||||
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
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}/download', [ServiceController::class, 'download'])->name('services.download');
|
||||||
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
|
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
|
||||||
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');
|
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');
|
||||||
|
|
@ -63,6 +64,7 @@
|
||||||
})->name('songs.index');
|
})->name('songs.index');
|
||||||
|
|
||||||
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.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('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
|
||||||
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue