Skip to content

Feature Implementation Plan: API Request Admin UI

Overview

Build a Cloudflare Security Analytics-style admin interface to visualize API request logs. The UI will display request patterns with expandable rows showing individual occurrences, filters for time range/path/method/cache status, and UUID resolution for human-readable display.

Dependencies: - api-request-logging.md (must be implemented first)

Related Plans: - api-cache-warming.md (will use patterns identified here)

Todo Checklist

  • [ ] Create ApiRequestLogViewerService for data retrieval and filtering
  • [ ] Create UuidResolverService for query param resolution
  • [ ] Create ApiRequestPatternCrudController with custom view action
  • [ ] Create api_request_logs.html.twig template
  • [ ] Add AJAX endpoint for loading occurrences
  • [ ] Add menu item to admin dashboard
  • [ ] Write tests for the services

Analysis & Investigation

Existing Patterns

The codebase has established patterns for custom admin views:

Service Pattern: RoasterUrlsService (262 lines) - Handles filtering, pagination, and stats aggregation - Returns structured array for template consumption - Query builder with dynamic filter application

Controller Pattern: RoasterCrudController::viewUrls() - Custom action using #[AdminAction] attribute - Injects service for business logic - Renders custom Twig template

Template Pattern: roaster_urls.html.twig - Extends @EasyAdmin/layout.html.twig - Stats cards at top - Filter form with dropdowns and inputs - Paginated table with actions - Pagination controls at bottom

Architecture Decisions

Decision Choice Rationale
Controller approach EasyAdmin CRUD + custom action Matches existing RoasterCrudController pattern
Expandable rows AJAX load on demand Performance - don't load thousands of occurrences upfront
Filters Service-based custom logic EasyAdmin filters too limited for time ranges
UUID resolution Dedicated caching service Centralized, reusable, cache-friendly
Column visibility JavaScript + localStorage Client-side preference, no backend needed

Implementation Plan

Prerequisites

  • ApiRequestPattern entity exists (from api-request-logging.md)
  • ApiRequestOccurrence entity exists (from api-request-logging.md)
  • Logging pipeline is operational

Step 1: Create UuidResolverService

File: src/Service/Admin/UuidResolverService.php

<?php

namespace App\Service\Admin;

use App\Entity\Roaster;
use App\Entity\Region;
use App\Entity\ProcessingMethod;
use App\Entity\RoastLevel;
use App\Entity\Variety;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Uid\Uuid;

final readonly class UuidResolverService
{
    private const ENTITY_MAP = [
        'roaster' => [Roaster::class, 'name'],
        'roasters' => [Roaster::class, 'name'],
        'region' => [Region::class, 'name'],
        'regions' => [Region::class, 'name'],
        'processingMethod' => [ProcessingMethod::class, 'name'],
        'processingMethods' => [ProcessingMethod::class, 'name'],
        'roastLevel' => [RoastLevel::class, 'name'],
        'roastLevels' => [RoastLevel::class, 'name'],
        'variety' => [Variety::class, 'name'],
        'varieties' => [Variety::class, 'name'],
    ];

    public function __construct(
        private EntityManagerInterface $entityManager,
        private CacheItemPoolInterface $cache,
    ) {}

    /**
     * Resolve UUIDs in query params to human-readable names.
     *
     * @param array<string, mixed> $queryParams
     * @return array<string, mixed> Resolved params with names instead of UUIDs
     */
    public function resolveQueryParams(array $queryParams): array
    {
        $resolved = [];

        foreach ($queryParams as $key => $value) {
            if (is_array($value)) {
                $resolved[$key] = array_map(
                    fn($v) => $this->resolveValue($key, $v),
                    $value
                );
            } else {
                $resolved[$key] = $this->resolveValue($key, $value);
            }
        }

        return $resolved;
    }

    /**
     * Format resolved params for display.
     */
    public function formatForDisplay(array $queryParams): string
    {
        $resolved = $this->resolveQueryParams($queryParams);
        $parts = [];

        foreach ($resolved as $key => $value) {
            if (is_array($value)) {
                $value = implode(', ', $value);
            }
            $parts[] = "{$key}={$value}";
        }

        return implode(' & ', $parts);
    }

    private function resolveValue(string $key, mixed $value): mixed
    {
        if (!is_string($value) || !$this->isUuid($value)) {
            return $value;
        }

        // Check if we know how to resolve this key
        $normalizedKey = $this->normalizeKey($key);
        if (!isset(self::ENTITY_MAP[$normalizedKey])) {
            return $value;
        }

        return $this->resolveName($value, self::ENTITY_MAP[$normalizedKey]) ?? $value;
    }

    private function normalizeKey(string $key): string
    {
        // Handle array notation: roasters[] -> roasters
        return rtrim($key, '[]');
    }

    private function isUuid(string $value): bool
    {
        return Uuid::isValid($value);
    }

    private function resolveName(string $uuid, array $entityConfig): ?string
    {
        [$entityClass, $field] = $entityConfig;

        $cacheKey = sprintf('uuid_name_%s_%s', md5($entityClass), $uuid);
        $cacheItem = $this->cache->getItem($cacheKey);

        if ($cacheItem->isHit()) {
            return $cacheItem->get();
        }

        $entity = $this->entityManager->find($entityClass, $uuid);
        if ($entity === null) {
            return null;
        }

        $getter = 'get' . ucfirst($field);
        $name = $entity->$getter();

        $cacheItem->set($name);
        $cacheItem->expiresAfter(3600); // 1 hour
        $this->cache->save($cacheItem);

        return $name;
    }
}

Step 2: Create ApiRequestLogViewerService

File: src/Service/Admin/ApiRequestLogViewerService.php

<?php

namespace App\Service\Admin;

use App\Entity\ApiRequestPattern;
use App\Entity\ApiRequestOccurrence;
use App\Repository\ApiRequestPatternRepository;
use App\Repository\ApiRequestOccurrenceRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;

final readonly class ApiRequestLogViewerService
{
    private const TIME_RANGES = [
        '1h' => '-1 hour',
        '24h' => '-24 hours',
        '7d' => '-7 days',
        '30d' => '-30 days',
        '90d' => '-90 days',
    ];

    public function __construct(
        private EntityManagerInterface $entityManager,
        private ApiRequestPatternRepository $patternRepository,
        private ApiRequestOccurrenceRepository $occurrenceRepository,
        private UuidResolverService $uuidResolver,
    ) {}

    public function getLogViewerData(Request $request): array
    {
        // Extract filter parameters
        $timeRange = $request->query->get('timeRange', '24h');
        $path = $request->query->get('path');
        $method = $request->query->get('method');
        $cacheStatus = $request->query->get('cacheStatus');
        $minHits = $request->query->getInt('minHits', 0);
        $page = max(1, $request->query->getInt('page', 1));
        $limit = 50;
        $offset = ($page - 1) * $limit;

        // Build query
        $qb = $this->entityManager->createQueryBuilder()
            ->select('p')
            ->from(ApiRequestPattern::class, 'p')
            ->orderBy('p.lastSeenAt', 'DESC');

        // Apply time range filter
        $timeStart = $this->getTimeRangeStart($timeRange);
        if ($timeStart !== null) {
            $qb->andWhere('p.lastSeenAt >= :timeStart')
               ->setParameter('timeStart', $timeStart);
        }

        // Apply path filter (partial match)
        if (!empty($path)) {
            $qb->andWhere('p.path LIKE :path')
               ->setParameter('path', '%' . $path . '%');
        }

        // Apply method filter
        if (!empty($method)) {
            $qb->andWhere('p.method = :method')
               ->setParameter('method', $method);
        }

        // Apply minimum hits filter
        if ($minHits > 0) {
            $qb->andWhere('p.hitCount >= :minHits')
               ->setParameter('minHits', $minHits);
        }

        // Count total for pagination
        $countQb = clone $qb;
        $countQb->select('COUNT(p.id)')
                ->resetDQLPart('orderBy');
        $totalPatterns = $countQb->getQuery()->getSingleScalarResult();

        // Apply pagination
        $qb->setFirstResult($offset)
           ->setMaxResults($limit);

        $patterns = $qb->getQuery()->getResult();

        // Resolve UUIDs and prepare display data
        $patternsWithDisplay = array_map(function (ApiRequestPattern $pattern) {
            return [
                'entity' => $pattern,
                'resolvedParams' => $this->uuidResolver->formatForDisplay($pattern->getQueryParams()),
            ];
        }, $patterns);

        // Calculate statistics
        $stats = $this->calculateStats($timeRange, $path, $method);

        // Calculate pagination
        $maxPage = max(1, (int) ceil($totalPatterns / $limit));
        $paginationRange = 5;
        $startPage = max(1, $page - (int) floor($paginationRange / 2));
        $endPage = min($maxPage, $startPage + $paginationRange - 1);
        $startPage = max(1, $endPage - $paginationRange + 1);

        return [
            'patterns' => $patternsWithDisplay,
            'stats' => $stats,
            'filters' => [
                'timeRange' => $timeRange,
                'path' => $path,
                'method' => $method,
                'cacheStatus' => $cacheStatus,
                'minHits' => $minHits,
            ],
            'timeRanges' => array_keys(self::TIME_RANGES),
            'methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
            'pagination' => [
                'current' => $page,
                'max' => $maxPage,
                'start' => $startPage,
                'end' => $endPage,
                'total' => $totalPatterns,
                'offset' => $offset,
                'limit' => $limit,
            ],
        ];
    }

    /**
     * Get occurrences for a specific pattern (for AJAX expansion).
     */
    public function getPatternOccurrences(string $patternId, int $limit = 10): array
    {
        $occurrences = $this->occurrenceRepository->findRecentByPattern($patternId, $limit);

        return array_map(function (ApiRequestOccurrence $occurrence) {
            return [
                'id' => $occurrence->getId(),
                'ipAddress' => $occurrence->getIpAddress(),
                'userAgent' => $this->truncateUserAgent($occurrence->getUserAgent()),
                'responseTimeMs' => round($occurrence->getResponseTimeMs(), 2),
                'cacheStatus' => $occurrence->getCacheStatus(),
                'statusCode' => $occurrence->getStatusCode(),
                'createdAt' => $occurrence->getCreatedAt()->format('Y-m-d H:i:s'),
            ];
        }, $occurrences);
    }

    private function calculateStats(string $timeRange, ?string $path, ?string $method): array
    {
        $timeStart = $this->getTimeRangeStart($timeRange);

        $baseQb = $this->entityManager->createQueryBuilder()
            ->from(ApiRequestPattern::class, 'p');

        if ($timeStart !== null) {
            $baseQb->andWhere('p.lastSeenAt >= :timeStart')
                   ->setParameter('timeStart', $timeStart);
        }

        if (!empty($path)) {
            $baseQb->andWhere('p.path LIKE :path')
                   ->setParameter('path', '%' . $path . '%');
        }

        if (!empty($method)) {
            $baseQb->andWhere('p.method = :method')
                   ->setParameter('method', $method);
        }

        // Total patterns
        $totalQb = clone $baseQb;
        $totalPatterns = $totalQb->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();

        // Total hits
        $hitsQb = clone $baseQb;
        $totalHits = $hitsQb->select('SUM(p.hitCount)')->getQuery()->getSingleScalarResult() ?? 0;

        // Average response time
        $avgQb = clone $baseQb;
        $avgResponseTime = $avgQb->select('AVG(p.avgResponseTimeMs)')->getQuery()->getSingleScalarResult() ?? 0;

        return [
            'totalPatterns' => (int) $totalPatterns,
            'totalHits' => (int) $totalHits,
            'avgResponseTime' => round((float) $avgResponseTime, 2),
        ];
    }

    private function getTimeRangeStart(string $timeRange): ?DateTimeImmutable
    {
        if (!isset(self::TIME_RANGES[$timeRange])) {
            return null;
        }

        return new DateTimeImmutable(self::TIME_RANGES[$timeRange]);
    }

    private function truncateUserAgent(?string $userAgent, int $maxLength = 50): ?string
    {
        if ($userAgent === null) {
            return null;
        }

        if (strlen($userAgent) <= $maxLength) {
            return $userAgent;
        }

        return substr($userAgent, 0, $maxLength) . '...';
    }
}

Step 3: Add Repository Method for Occurrences

File: src/Repository/ApiRequestOccurrenceRepository.php (add method)

/**
 * Find recent occurrences for a pattern.
 *
 * @return ApiRequestOccurrence[]
 */
public function findRecentByPattern(string $patternId, int $limit = 10): array
{
    return $this->createQueryBuilder('o')
        ->andWhere('o.pattern = :patternId')
        ->setParameter('patternId', $patternId)
        ->orderBy('o.createdAt', 'DESC')
        ->setMaxResults($limit)
        ->getQuery()
        ->getResult();
}

/**
 * Delete occurrences older than N days.
 */
public function deleteOlderThan(int $days): int
{
    $threshold = new DateTimeImmutable("-{$days} days");

    return $this->createQueryBuilder('o')
        ->delete()
        ->where('o.createdAt < :threshold')
        ->setParameter('threshold', $threshold)
        ->getQuery()
        ->execute();
}

Step 4: Create ApiRequestPatternCrudController

File: src/Controller/Admin/ApiRequestPatternCrudController.php

<?php

namespace App\Controller\Admin;

use App\Entity\ApiRequestPattern;
use App\Service\Admin\ApiRequestLogViewerService;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminAction;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\NumberField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class ApiRequestPatternCrudController extends AbstractCrudController
{
    public function __construct(
        private readonly ApiRequestLogViewerService $logViewerService,
    ) {}

    public static function getEntityFqcn(): string
    {
        return ApiRequestPattern::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->setEntityLabelInSingular('API Request Pattern')
            ->setEntityLabelInPlural('API Request Patterns')
            ->setDefaultSort(['lastSeenAt' => 'DESC'])
            ->showEntityActionsInlined();
    }

    public function configureActions(Actions $actions): Actions
    {
        $viewLogs = Action::new('viewLogs', 'View Logs')
            ->linkToCrudAction('viewLogs')
            ->setIcon('fa fa-chart-line')
            ->createAsGlobalAction();

        return $actions
            ->add(Crud::PAGE_INDEX, $viewLogs)
            ->disable(Action::NEW, Action::EDIT)
            ->reorder(Crud::PAGE_INDEX, ['viewLogs', Action::DETAIL, Action::DELETE]);
    }

    public function configureFields(string $pageName): iterable
    {
        yield IdField::new('id')->hideOnIndex();
        yield TextField::new('method')->setLabel('Method');
        yield TextField::new('path')->setLabel('Path');
        yield IntegerField::new('hitCount')->setLabel('Hits');
        yield NumberField::new('avgResponseTimeMs')
            ->setLabel('Avg Response (ms)')
            ->setNumDecimals(2);
        yield DateTimeField::new('lastSeenAt')->setLabel('Last Seen');
        yield DateTimeField::new('createdAt')->setLabel('First Seen')->hideOnIndex();
    }

    #[AdminAction('/', 'view_logs')]
    public function viewLogs(Request $request): Response
    {
        $data = $this->logViewerService->getLogViewerData($request);

        return $this->render('admin/api_request_logs.html.twig', $data);
    }

    #[AdminAction('/{patternId}/occurrences', 'get_occurrences', methods: ['GET'])]
    public function getOccurrences(Request $request, string $patternId): JsonResponse
    {
        $limit = $request->query->getInt('limit', 10);
        $occurrences = $this->logViewerService->getPatternOccurrences($patternId, $limit);

        return $this->json($occurrences);
    }
}

Step 5: Create Template

File: templates/admin/api_request_logs.html.twig

{% extends '@EasyAdmin/layout.html.twig' %}

{% block page_title %}API Request Logs{% endblock %}

{% block head_stylesheets %}
    {{ parent() }}
    <style>
        .pattern-row { cursor: pointer; }
        .pattern-row:hover { background-color: #f8f9fa; }
        .occurrences-row { display: none; background-color: #f1f3f5; }
        .occurrences-row.expanded { display: table-row; }
        .occurrences-table { margin: 0; font-size: 0.875rem; }
        .expand-icon { transition: transform 0.2s; }
        .expand-icon.rotated { transform: rotate(90deg); }
        .cache-hit { color: #28a745; }
        .cache-miss { color: #dc3545; }
        .cache-stale { color: #ffc107; }
        .status-2xx { color: #28a745; }
        .status-4xx { color: #ffc107; }
        .status-5xx { color: #dc3545; }
        .query-params { font-family: monospace; font-size: 0.8rem; color: #6c757d; }
    </style>
{% endblock %}

{% block main %}
    {% set base_url = ea_url()
        .setController('App\\Controller\\Admin\\ApiRequestPatternCrudController')
        .setAction('viewLogs') %}

    {# Stats Cards #}
    <div class="row mb-4">
        <div class="col-md-4">
            <div class="card bg-light">
                <div class="card-body text-center">
                    <h2 class="card-title">{{ stats.totalPatterns }}</h2>
                    <p class="card-text mb-0">Unique Patterns</p>
                </div>
            </div>
        </div>
        <div class="col-md-4">
            <div class="card bg-light">
                <div class="card-body text-center">
                    <h2 class="card-title">{{ stats.totalHits|number_format }}</h2>
                    <p class="card-text mb-0">Total Requests</p>
                </div>
            </div>
        </div>
        <div class="col-md-4">
            <div class="card bg-light">
                <div class="card-body text-center">
                    <h2 class="card-title">{{ stats.avgResponseTime }} ms</h2>
                    <p class="card-text mb-0">Avg Response Time</p>
                </div>
            </div>
        </div>
    </div>

    {# Filters #}
    <div class="card mb-4">
        <div class="card-header">
            <h5 class="card-title mb-0">Filters</h5>
        </div>
        <div class="card-body">
            <form method="get" action="{{ base_url }}" class="row g-3">
                <div class="col-md-2">
                    <label for="timeRange" class="form-label">Time Range</label>
                    <select name="timeRange" id="timeRange" class="form-select">
                        {% for range in timeRanges %}
                            <option value="{{ range }}" {% if filters.timeRange == range %}selected{% endif %}>
                                {{ range }}
                            </option>
                        {% endfor %}
                    </select>
                </div>
                <div class="col-md-3">
                    <label for="path" class="form-label">Path</label>
                    <input type="text" class="form-control" id="path" name="path"
                           value="{{ filters.path }}" placeholder="/api/coffee-beans">
                </div>
                <div class="col-md-2">
                    <label for="method" class="form-label">Method</label>
                    <select name="method" id="method" class="form-select">
                        <option value="">All</option>
                        {% for m in methods %}
                            <option value="{{ m }}" {% if filters.method == m %}selected{% endif %}>{{ m }}</option>
                        {% endfor %}
                    </select>
                </div>
                <div class="col-md-2">
                    <label for="minHits" class="form-label">Min Hits</label>
                    <input type="number" class="form-control" id="minHits" name="minHits"
                           value="{{ filters.minHits }}" min="0" placeholder="0">
                </div>
                <div class="col-md-3 d-flex align-items-end">
                    <button type="submit" class="btn btn-primary me-2">Apply</button>
                    <a href="{{ base_url }}" class="btn btn-outline-secondary">Reset</a>
                </div>
            </form>
        </div>
    </div>

    {# Results Table #}
    <div class="card">
        <div class="card-header d-flex justify-content-between align-items-center">
            <h5 class="card-title mb-0">
                Request Patterns
                ({{ pagination.total }} total, showing {{ pagination.offset + 1 }}-{{ min(pagination.offset + pagination.limit, pagination.total) }})
            </h5>
        </div>
        <div class="card-body p-0">
            <div class="table-responsive">
                <table class="table table-hover mb-0">
                    <thead class="table-light">
                        <tr>
                            <th style="width: 40px;"></th>
                            <th>Last Seen</th>
                            <th>Method</th>
                            <th>Path</th>
                            <th>Query Params</th>
                            <th class="text-end">Hits</th>
                            <th class="text-end">Avg Time</th>
                        </tr>
                    </thead>
                    <tbody>
                        {% for item in patterns %}
                            {% set pattern = item.entity %}
                            <tr class="pattern-row" data-pattern-id="{{ pattern.id }}" onclick="toggleOccurrences('{{ pattern.id }}')">
                                <td>
                                    <i class="fas fa-chevron-right expand-icon" id="icon-{{ pattern.id }}"></i>
                                </td>
                                <td>{{ pattern.lastSeenAt|date('M d, Y H:i') }}</td>
                                <td><span class="badge bg-secondary">{{ pattern.method }}</span></td>
                                <td><code>{{ pattern.path }}</code></td>
                                <td class="query-params">{{ item.resolvedParams|default('-')|truncate(60) }}</td>
                                <td class="text-end">{{ pattern.hitCount|number_format }}</td>
                                <td class="text-end">{{ pattern.avgResponseTimeMs|number_format(2) }} ms</td>
                            </tr>
                            <tr class="occurrences-row" id="occurrences-{{ pattern.id }}">
                                <td colspan="7">
                                    <div class="p-3">
                                        <h6>Recent Occurrences</h6>
                                        <div id="occurrences-content-{{ pattern.id }}">
                                            <div class="text-muted">Loading...</div>
                                        </div>
                                    </div>
                                </td>
                            </tr>
                        {% else %}
                            <tr>
                                <td colspan="7" class="text-center text-muted py-4">
                                    No request patterns found for the selected filters.
                                </td>
                            </tr>
                        {% endfor %}
                    </tbody>
                </table>
            </div>
        </div>

        {# Pagination #}
        {% if pagination.max > 1 %}
            <div class="card-footer">
                <nav>
                    <ul class="pagination justify-content-center mb-0">
                        {% if pagination.current > 1 %}
                            <li class="page-item">
                                <a class="page-link" href="{{ base_url.set('page', 1) }}{% for key, value in filters %}{% if value %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
                                    &laquo;
                                </a>
                            </li>
                        {% endif %}

                        {% for p in pagination.start..pagination.end %}
                            <li class="page-item {% if p == pagination.current %}active{% endif %}">
                                <a class="page-link" href="{{ base_url.set('page', p) }}{% for key, value in filters %}{% if value %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
                                    {{ p }}
                                </a>
                            </li>
                        {% endfor %}

                        {% if pagination.current < pagination.max %}
                            <li class="page-item">
                                <a class="page-link" href="{{ base_url.set('page', pagination.max) }}{% for key, value in filters %}{% if value %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
                                    &raquo;
                                </a>
                            </li>
                        {% endif %}
                    </ul>
                </nav>
            </div>
        {% endif %}
    </div>
{% endblock %}

{% block body_javascript %}
    {{ parent() }}
    <script>
        const loadedPatterns = new Set();
        const occurrencesUrl = '{{ ea_url()
            .setController("App\\\\Controller\\\\Admin\\\\ApiRequestPatternCrudController")
            .setAction("getOccurrences") }}';

        function toggleOccurrences(patternId) {
            const row = document.getElementById(`occurrences-${patternId}`);
            const icon = document.getElementById(`icon-${patternId}`);
            const isExpanded = row.classList.contains('expanded');

            if (isExpanded) {
                row.classList.remove('expanded');
                icon.classList.remove('rotated');
            } else {
                row.classList.add('expanded');
                icon.classList.add('rotated');

                if (!loadedPatterns.has(patternId)) {
                    loadOccurrences(patternId);
                }
            }
        }

        function loadOccurrences(patternId) {
            const contentDiv = document.getElementById(`occurrences-content-${patternId}`);

            fetch(`${occurrencesUrl}/${patternId}/occurrences`)
                .then(response => response.json())
                .then(data => {
                    loadedPatterns.add(patternId);
                    renderOccurrences(contentDiv, data);
                })
                .catch(error => {
                    contentDiv.innerHTML = '<div class="text-danger">Failed to load occurrences</div>';
                });
        }

        function renderOccurrences(container, occurrences) {
            if (occurrences.length === 0) {
                container.innerHTML = '<div class="text-muted">No occurrences found</div>';
                return;
            }

            let html = `
                <table class="table table-sm occurrences-table">
                    <thead>
                        <tr>
                            <th>Time</th>
                            <th>IP</th>
                            <th>User Agent</th>
                            <th>Response Time</th>
                            <th>Cache</th>
                            <th>Status</th>
                        </tr>
                    </thead>
                    <tbody>
            `;

            occurrences.forEach(occ => {
                const cacheClass = occ.cacheStatus === 'HIT' ? 'cache-hit' :
                                   occ.cacheStatus === 'MISS' ? 'cache-miss' : 'cache-stale';
                const statusClass = occ.statusCode < 300 ? 'status-2xx' :
                                    occ.statusCode < 500 ? 'status-4xx' : 'status-5xx';

                html += `
                    <tr>
                        <td>${occ.createdAt}</td>
                        <td>${occ.ipAddress || '-'}</td>
                        <td>${occ.userAgent || '-'}</td>
                        <td>${occ.responseTimeMs} ms</td>
                        <td class="${cacheClass}">${occ.cacheStatus || '-'}</td>
                        <td class="${statusClass}">${occ.statusCode}</td>
                    </tr>
                `;
            });

            html += '</tbody></table>';
            container.innerHTML = html;
        }
    </script>
{% endblock %}

Step 6: Add Menu Item to Dashboard

File: src/Controller/Admin/DashboardController.php (add to configureMenuItems())

yield MenuItem::section('Analytics');
yield MenuItem::linkToCrud('API Request Logs', 'fa fa-chart-line', ApiRequestPattern::class)
    ->setController(ApiRequestPatternCrudController::class)
    ->setAction('viewLogs');

Testing Strategy

Unit Tests

  1. UuidResolverService
  2. Test UUID detection
  3. Test resolution caching
  4. Test handling of unknown entity types

  5. ApiRequestLogViewerService

  6. Test time range filtering
  7. Test pagination calculations
  8. Test stats aggregation

Integration Tests

  1. Full flow test:
  2. Create test patterns and occurrences
  3. Request log viewer page
  4. Verify correct filtering and display

  5. AJAX endpoint test:

  6. Create pattern with occurrences
  7. Request occurrences endpoint
  8. Verify JSON response structure

Manual Testing

  1. Navigate to Admin → API Request Logs
  2. Verify stats cards show correct aggregations
  3. Apply filters and verify results update
  4. Click pattern row to expand occurrences
  5. Verify UUIDs are resolved to names

Success Criteria

  • [ ] Log viewer displays patterns with expandable rows
  • [ ] Time range filter correctly limits results
  • [ ] Path/method filters work as expected
  • [ ] UUID parameters display as human-readable names
  • [ ] AJAX expansion loads occurrences without page reload
  • [ ] Pagination works correctly
  • [ ] Stats cards show accurate aggregations
  • [ ] UI matches Cloudflare Security Analytics style

Future Enhancements (Out of Scope)

  1. Export functionality - CSV/JSON export of filtered results
  2. Real-time updates - WebSocket for live log streaming
  3. Graphs/charts - Visual trends over time
  4. Column visibility toggle - Remember preferences in localStorage
  5. Saved filters - Store commonly used filter combinations