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
ApiRequestLogViewerServicefor data retrieval and filtering - [ ] Create
UuidResolverServicefor query param resolution - [ ] Create
ApiRequestPatternCrudControllerwith custom view action - [ ] Create
api_request_logs.html.twigtemplate - [ ] 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¶
ApiRequestPatternentity exists (fromapi-request-logging.md)ApiRequestOccurrenceentity exists (fromapi-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 %}">
«
</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 %}">
»
</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¶
- UuidResolverService
- Test UUID detection
- Test resolution caching
-
Test handling of unknown entity types
-
ApiRequestLogViewerService
- Test time range filtering
- Test pagination calculations
- Test stats aggregation
Integration Tests¶
- Full flow test:
- Create test patterns and occurrences
- Request log viewer page
-
Verify correct filtering and display
-
AJAX endpoint test:
- Create pattern with occurrences
- Request occurrences endpoint
- Verify JSON response structure
Manual Testing¶
- Navigate to Admin → API Request Logs
- Verify stats cards show correct aggregations
- Apply filters and verify results update
- Click pattern row to expand occurrences
- 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)¶
- Export functionality - CSV/JSON export of filtered results
- Real-time updates - WebSocket for live log streaming
- Graphs/charts - Visual trends over time
- Column visibility toggle - Remember preferences in localStorage
- Saved filters - Store commonly used filter combinations