presseportale/resources/views/livewire/admin/reports/index-status.blade.php
Kevin Adametz 6b8728242f admin: Indexierungsstatus aus der Search Console, Kundenansicht vorbereitet und zu (Anhang 1: 1c.4, E8)
press-releases:check-index-status fragt täglich je Portal ein Kontingent
veröffentlichter Meldungen über die URL Inspection API ab, nach Alter
gestaffelt, nie geprüfte zuerst. Jede Antwort wird als Momentaufnahme in
press_release_index_checks gespeichert; der reduzierte Befund (IndexState)
trennt „noch nicht gecrawlt" von „gecrawlt, nicht indexiert".

Client authentifiziert sich als Service-Konto über ein selbst signiertes
JWT — kein Google-SDK. Admin ▸ Reports ▸ Indexierung zeigt die Verteilung
je Portal getrennt nach Direkteinreichung und Syndikation (Status-Log
source=api; Altbestand ohne Log zählt als direkt).

Das Kundenpanel heißt jetzt „Auslieferung", zeigt Sitemap-Datum und die
Abrufe durch Such- und KI-Dienste mit dem Satz „Ein Abruf ist kein Zitat";
der Google-Index-Block hängt an GSC_CUSTOMER_VISIBLE (false).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:21:51 +00:00

182 lines
10 KiB
PHP

<?php
use App\Enums\IndexState;
use App\Enums\Portal;
use App\Services\SearchConsole\IndexStatusReport;
use App\Services\SearchConsole\SearchConsoleClient;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Admin ▸ Reports ▸ Indexierung (Roadmap 1c.4).
*
* Verteilung der jüngsten Search-Console-Befunde je Portal, getrennt nach
* Direkteinreichung und Syndikation über die API. Diese Verteilung ist die
* Grundlage für 1c.5 (Kundenansicht ja/nein, E8) — der Kunde sieht bis
* dahin nichts davon.
*/
new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Component
{
public function downloadLatest(): StreamedResponse
{
$rows = app(IndexStatusReport::class)->latest(2000);
$name = sprintf('indexierung-%s.csv', now()->format('Y-m-d'));
return response()->streamDownload(function () use ($rows): void {
$handle = fopen('php://output', 'wb');
fputcsv($handle, ['Geprüft', 'Portal', 'ID', 'Titel', 'URL', 'Befund', 'Google-Status', 'Zuletzt gecrawlt', 'Kanonisch'], ';');
foreach ($rows as $check) {
fputcsv($handle, [
$check->checked_at->format('d.m.Y H:i'),
$check->portal->label(),
$check->press_release_id,
$check->pressRelease?->title ?? '',
$check->url,
$check->state->label(),
$check->coverage_state ?? '',
$check->last_crawl_time?->format('d.m.Y') ?? '',
$check->google_canonical ?? '',
], ';');
}
fclose($handle);
}, $name, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function with(): array
{
$report = app(IndexStatusReport::class);
return [
'configured' => app(SearchConsoleClient::class)->isConfigured(),
'customerVisible' => (bool) config('search_console.customer_visible'),
'distribution' => $report->distribution(),
'latest' => $report->latest(100),
'lastCheckedAt' => $report->lastCheckedAt(),
'states' => IndexState::cases(),
'portals' => [Portal::Presseecho, Portal::Businessportal24],
];
}
}; ?>
<div class="space-y-8">
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Reports')"
:title="__('Indexierung')"
:description="__('Stand der jüngsten Search-Console-Prüfung je Meldung, getrennt nach Direkteinreichung und Syndikation über die API. „Noch nicht gecrawlt“ und „gecrawlt, nicht indexiert“ sind verschiedene Befunde. Die Herkunft ist nur für Meldungen aus dem neuen System belegbar — der Altbestand ohne Status-Log zählt als direkt. Der Kunde sieht diese Daten erst, wenn die Anzeige freigegeben ist.')"
/>
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-portal.stat-card :variant="$configured ? 'primary' : 'warn'" :label="__('Search Console')" :value="$configured ? __('verbunden') : __('nicht konfiguriert')">
<x-slot:meta>{{ $configured ? __('täglicher Lauf 05:10 Uhr') : __('GSC_ENABLED und Schlüsseldatei setzen') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Letzte Prüfung')" :value="$lastCheckedAt?->format('d.m.Y H:i') ?? '—'">
<x-slot:meta>{{ __('Momentaufnahmen, jede Antwort eine Zeile') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card :variant="$customerVisible ? 'warn' : 'muted'" :label="__('Kundenansicht')" :value="$customerVisible ? __('offen') : __('geschlossen')">
<x-slot:meta>{{ __('Entscheidung E8 — config search_console.customer_visible') }}</x-slot:meta>
</x-portal.stat-card>
</section>
@foreach ($portals as $portal)
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ $portal->label() }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('im Archiv sichtbare Meldungen, Stand der jeweils jüngsten Prüfung') }}</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Herkunft') }}</flux:table.column>
<flux:table.column>{{ __('Meldungen') }}</flux:table.column>
@foreach ($states as $state)
<flux:table.column>{{ ucfirst($state->label()) }}</flux:table.column>
@endforeach
<flux:table.column>{{ __('Ungeprüft') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach (['direct' => __('Direkt eingereicht'), 'api' => __('Syndikation (API)')] as $origin => $label)
@php
$row = $distribution[$portal->value][$origin] ?? [];
$checkedTotal = max(0, ($row['total'] ?? 0) - ($row['unchecked'] ?? 0));
@endphp
<flux:table.row :key="$portal->value.'-'.$origin">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $label }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($row['total'] ?? 0, 0, ',', '.') }}</span>
</flux:table.cell>
@foreach ($states as $state)
@php $count = (int) ($row[$state->value] ?? 0); @endphp
<flux:table.cell>
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($count, 0, ',', '.') }}</span>
@if ($checkedTotal > 0)
<span class="block text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ number_format($count / $checkedTotal * 100, 1, ',', '.') }} %</span>
@endif
</flux:table.cell>
@endforeach
<flux:table.cell>
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ number_format($row['unchecked'] ?? 0, 0, ',', '.') }}</span>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</article>
@endforeach
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Jüngste Prüfungen') }}</span>
@if ($latest->isNotEmpty())
<flux:button size="sm" variant="filled" icon="arrow-down-tray" wire:click="downloadLatest">{{ __('CSV') }}</flux:button>
@endif
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Geprüft') }}</flux:table.column>
<flux:table.column>{{ __('Meldung') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Befund') }}</flux:table.column>
<flux:table.column>{{ __('Zuletzt gecrawlt') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($latest as $check)
<flux:table.row :key="$check->id">
<flux:table.cell>
<span class="text-[12px] tabular-nums text-[color:var(--color-ink-3)]">{{ $check->checked_at->format('d.m.Y H:i') }}</span>
</flux:table.cell>
<flux:table.cell>
<a href="{{ route('admin.press-releases.show', $check->press_release_id) }}" class="text-[13px] font-semibold text-[color:var(--color-ink)] hover:underline">{{ $check->pressRelease?->title ?? '#'.$check->press_release_id }}</a>
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ __('veröffentlicht :date', ['date' => $check->pressRelease?->published_at?->format('d.m.Y') ?? '—']) }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $check->portal->label() }}</span>
</flux:table.cell>
<flux:table.cell>
<span @class(['badge dot', 'ok' => $check->state === IndexState::Indexed, 'warn' => in_array($check->state, [IndexState::CrawledNotIndexed, IndexState::NotCrawled], true), 'err' => $check->state === IndexState::Excluded, 'muted' => $check->state === IndexState::Unknown])>{{ $check->state->label() }}</span>
@if ($check->coverage_state)
<span class="block text-[11px] text-[color:var(--color-ink-4)] mt-1">{{ $check->coverage_state }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $check->last_crawl_time?->format('d.m.Y') ?? '—' }}</span>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Prüfung gespeichert. Erster Lauf: php artisan press-releases:check-index-status') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
</article>
</div>