listUseCase = $listUseCase; $this->summaryUseCase = $summaryUseCase; } /** * Echo the §6 Logs page HTML to stdout. * * @param LogListQuery $query Already-validated list query. */ public function render( LogListQuery $query ): void { echo '
'; echo '

' . esc_html( 'ログ' ) . '

'; $this->renderEmbedded( $query, self::MENU_SLUG, '' ); echo '
'; } /** * Echo logs content without the outer admin-page wrapper. * * @param LogListQuery $query Current query. * @param string $menuSlug Admin page slug for embedded forms. * @param string $anchor Optional URL fragment to preserve tab focus. */ public function renderEmbedded( LogListQuery $query, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { $this->renderSummary(); $this->renderFilterForm( $query, $menuSlug, $anchor ); $result = $this->listUseCase->execute( $query ); if ( $result->items() === array() ) { echo '

' . esc_html( '条件に一致するログはありません。' ) . '

'; return; } echo ''; $this->renderTableHead(); echo ''; foreach ( $result->items() as $row ) { $this->renderRow( $row ); } echo ''; echo '
'; $this->renderPagination( $query, $result->pageCount(), $menuSlug, $anchor ); } /** * Render the §6 summary card: a small `` listing each * PSR-3 level → row count. * * Failures from the summary use case are caught silently so the * filter / table half of the page still renders. The list use * case is the canonical failure surface for the §6 page. */ private function renderSummary(): void { echo '

' . esc_html( '概要' ) . '

'; try { $summary = $this->summaryUseCase->execute(); } catch ( \Throwable $e ) { unset( $e ); echo '

' . esc_html( '概要は一時的に利用できません。' ) . '

'; return; } $counts = $summary->counts(); if ( $counts === array() ) { echo '

' . esc_html( 'ログはまだ記録されていません。' ) . '

'; return; } echo '
'; echo ''; foreach ( array( 'レベル', '件数' ) as $heading ) { echo ''; } echo ''; echo ''; foreach ( $counts as $level => $count ) { echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( (string) $level ) . '' . esc_html( (string) (int) $count ) . '
'; } /** * Render the §6 filter form. * * The form submits via GET back to the same admin page so the * parsed query state survives a page reload; the implicit * `page=affilun-logs` hidden field keeps the WordPress admin router * pointing at this page across the round-trip. * * @param LogListQuery $query Current query (drives selected option state). * @param string $menuSlug Admin page slug for the form round-trip. * @param string $anchor Optional URL fragment to preserve tab focus. */ private function renderFilterForm( LogListQuery $query, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { echo '
'; echo ''; echo ''; echo '
'; } /** * Render the §6 results table ``. */ private function renderTableHead(): void { echo ''; foreach ( array( 'ID', '作成日時', 'チャネル', 'レベル', 'イベント種別', 'メッセージ', 'リクエスト ID', '相関 ID', 'ジョブ ID', '関連商品', '関連ジョブ', ) as $heading ) { echo '' . esc_html( $heading ) . ''; } echo ''; } /** * Render a single masked row. * * @param array $row Masked row from the use case. */ private function renderRow( array $row ): void { $id = $row['id'] ?? null; $createdAt = $row['created_at'] ?? ''; $channel = $row['channel'] ?? ''; $level = $row['level'] ?? ''; $eventType = $row['event_type'] ?? null; $message = $row['message'] ?? ''; $requestId = $row['request_id'] ?? null; $correlationId = $row['correlation_id'] ?? null; $jobId = $row['job_id'] ?? null; $relatedProductId = $row['related_product_id'] ?? null; $relatedJobId = $row['related_job_id'] ?? null; echo ''; echo '' . esc_html( $id === null ? '' : (string) $id ) . ''; echo '' . esc_html( AdminTimeFormatter::format( (string) $createdAt ) ) . ''; echo '' . esc_html( (string) $channel ) . ''; echo '' . esc_html( (string) $level ) . ''; echo '' . esc_html( $eventType === null ? '' : (string) $eventType ) . ''; echo '' . esc_html( (string) $message ) . ''; echo '' . esc_html( $requestId === null ? '' : (string) $requestId ) . ''; echo '' . esc_html( $correlationId === null ? '' : (string) $correlationId ) . ''; echo '' . esc_html( $jobId === null ? '' : (string) $jobId ) . ''; echo '' . esc_html( $relatedProductId === null ? '' : (string) $relatedProductId ) . ''; echo '' . esc_html( $relatedJobId === null ? '' : (string) $relatedJobId ) . ''; echo ''; } /** * Render the textual `Page X of Y` pagination nav with prev / * next anchors that preserve the current filter state. * * @param LogListQuery $query Originating query. * @param int $totalPages Total page count. * @param string $menuSlug Admin page slug for generated links. * @param string $anchor Optional URL fragment to preserve tab focus. */ private function renderPagination( LogListQuery $query, int $totalPages, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { $current = $query->page(); echo '
'; echo ''; echo esc_html( sprintf( '%d / %d ページ', $current, max( 1, $totalPages ) ) ); echo ''; if ( $totalPages > 1 ) { echo ' '; if ( $current > 1 ) { $prevUrl = self::buildPageUrl( $current - 1, $query, $menuSlug, $anchor ); echo '' . esc_html( '« 前へ' ) . ' '; } if ( $current < $totalPages ) { $nextUrl = self::buildPageUrl( $current + 1, $query, $menuSlug, $anchor ); echo '' . esc_html( '次へ »' ) . ''; } echo ''; } echo '
'; } /** * Build the per-page URL preserving the current filter state. * * Prefers `add_query_arg()` (the WordPress helper handles * existing query-string fragments correctly) and falls back to * manual `http_build_query()` concatenation in pure-unit * harnesses where WordPress is not loaded. * * @param int $page Target page (>= 1). * @param LogListQuery $query Current query state. * @param string $menuSlug Admin page slug for generated links. * @param string $anchor Optional URL fragment to preserve tab focus. */ private static function buildPageUrl( int $page, LogListQuery $query, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): string { $args = array( 'page' => $menuSlug, LogsListQueryParser::KEY_PAGE => $page, ); if ( $query->level() !== null ) { $args[ LogsListQueryParser::KEY_LEVEL ] = $query->level(); } if ( $query->channel() !== null ) { $args[ LogsListQueryParser::KEY_CHANNEL ] = $query->channel(); } if ( $query->relatedProductId() !== null ) { $args[ LogsListQueryParser::KEY_RELATED_PRODUCT_ID ] = $query->relatedProductId(); } if ( $query->relatedJobId() !== null ) { $args[ LogsListQueryParser::KEY_RELATED_JOB_ID ] = $query->relatedJobId(); } if ( $query->perPage() !== LogListQuery::DEFAULT_PER_PAGE ) { $args[ LogsListQueryParser::KEY_PER_PAGE ] = $query->perPage(); } $base = self::resolveAdminUrl( 'admin.php' ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $base ) . $anchor; } $separator = str_contains( $base, '?' ) ? '&' : '?'; return $base . $separator . http_build_query( $args ) . $anchor; } /** * Resolve a form action URL for embedded filter forms. * * @param string $anchor Optional URL fragment to preserve tab focus. */ private static function formActionUrl( string $anchor ): string { return $anchor === '' ? '' : self::resolveAdminUrl( 'admin.php' ) . $anchor; } /** * Resolve an admin URL via `admin_url()` when available, * falling back to a synthetic absolute URL in pure-unit * harnesses where WordPress is not loaded so `esc_url()` does * not strip the value. * * @param string $path Admin path (e.g. `'admin.php'`). */ private static function resolveAdminUrl( string $path ): string { if ( function_exists( 'admin_url' ) ) { return (string) admin_url( $path ); } return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); } }