Add admin Infra page with Redis and PostgreSQL monitoring

Shows real-time stats with color-coded indicators:
- Redis: version, memory, hit rate, ops/sec, evicted keys
- PostgreSQL: version, db size, connections, cache hit ratio, dead tuples
Uses MESSENGER_TRANSPORT_DSN for Redis auth (works in dev and prod).
Accessible via /admin/infra with nav link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 10:27:15 +01:00
parent e4edc76f58
commit 58301840a6
4 changed files with 223 additions and 0 deletions

View File

@@ -648,6 +648,108 @@ class AdminController extends AbstractController
]);
}
#[Route('/infra', name: 'app_admin_infra', methods: ['GET'])]
public function infra(EntityManagerInterface $em, #[Autowire(env: 'MESSENGER_TRANSPORT_DSN')] string $redisDsn): Response
{
$redisInfo = $this->getRedisInfo($redisDsn);
$pgInfo = $this->getPostgresInfo($em);
return $this->render('admin/infra.html.twig', [
'redis' => $redisInfo,
'postgres' => $pgInfo,
]);
}
/**
* @return array<string, mixed>
*/
private function getRedisInfo(string $dsn): array
{
try {
$parsed = parse_url(str_replace('redis://', 'http://', $dsn));
$host = $parsed['host'] ?? 'redis';
$port = $parsed['port'] ?? 6379;
$pass = null;
if (isset($parsed['pass']) && '' !== $parsed['pass']) {
$pass = urldecode($parsed['pass']);
} elseif (isset($parsed['user']) && '' !== $parsed['user']) {
$pass = urldecode($parsed['user']);
}
$redis = new \Redis();
$redis->connect($host, (int) $port, 2);
if (null !== $pass) {
$redis->auth($pass);
}
$info = $redis->info();
$redis->close();
return [
'connected' => true,
'version' => $info['redis_version'] ?? '?',
'uptime_days' => isset($info['uptime_in_seconds']) ? round((int) $info['uptime_in_seconds'] / 86400, 1) : '?',
'used_memory_human' => $info['used_memory_human'] ?? '?',
'used_memory_peak_human' => $info['used_memory_peak_human'] ?? '?',
'connected_clients' => $info['connected_clients'] ?? '?',
'total_commands_processed' => $info['total_commands_processed'] ?? '?',
'keyspace_hits' => $info['keyspace_hits'] ?? '0',
'keyspace_misses' => $info['keyspace_misses'] ?? '0',
'hit_rate' => $this->computeHitRate($info),
'expired_keys' => $info['expired_keys'] ?? '0',
'evicted_keys' => $info['evicted_keys'] ?? '0',
'instantaneous_ops_per_sec' => $info['instantaneous_ops_per_sec'] ?? '?',
'role' => $info['role'] ?? '?',
];
} catch (\Throwable $e) {
return ['connected' => false, 'error' => $e->getMessage()];
}
}
private function computeHitRate(array $info): string
{
$hits = (int) ($info['keyspace_hits'] ?? 0);
$misses = (int) ($info['keyspace_misses'] ?? 0);
$total = $hits + $misses;
return $total > 0 ? round($hits / $total * 100, 1).'%' : 'N/A';
}
/**
* @return array<string, mixed>
*/
private function getPostgresInfo(EntityManagerInterface $em): array
{
try {
$conn = $em->getConnection();
$version = $conn->executeQuery('SHOW server_version')->fetchOne();
$dbSize = $conn->executeQuery("SELECT pg_size_pretty(pg_database_size(current_database()))")->fetchOne();
$activeConns = $conn->executeQuery("SELECT count(*) FROM pg_stat_activity WHERE state = 'active'")->fetchOne();
$totalConns = $conn->executeQuery('SELECT count(*) FROM pg_stat_activity')->fetchOne();
$maxConns = $conn->executeQuery('SHOW max_connections')->fetchOne();
$uptime = $conn->executeQuery('SELECT now() - pg_postmaster_start_time()')->fetchOne();
$cacheHit = $conn->executeQuery("SELECT round(sum(blks_hit)::numeric / nullif(sum(blks_hit + blks_read), 0) * 100, 2) FROM pg_stat_database")->fetchOne();
$deadTuples = $conn->executeQuery('SELECT sum(n_dead_tup) FROM pg_stat_user_tables')->fetchOne();
$tableCount = $conn->executeQuery("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'")->fetchOne();
return [
'connected' => true,
'version' => $version,
'db_size' => $dbSize,
'active_connections' => $activeConns,
'total_connections' => $totalConns,
'max_connections' => $maxConns,
'uptime' => $uptime,
'cache_hit_ratio' => $cacheHit ? $cacheHit.'%' : 'N/A',
'dead_tuples' => $deadTuples ?? '0',
'table_count' => $tableCount,
];
} catch (\Throwable $e) {
return ['connected' => false, 'error' => $e->getMessage()];
}
}
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response
{

View File

@@ -24,6 +24,7 @@
<a href="{{ path('app_admin_events') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_event' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Evenements</a>
<a href="{{ path('app_admin_orders') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_orders' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Commandes</a>
<a href="{{ path('app_admin_logs') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_logs' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Logs</a>
<a href="{{ path('app_admin_infra') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_infra' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Infra</a>
</nav>
</div>
<div class="flex items-center gap-3">

View File

@@ -0,0 +1,98 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Infrastructure{% endblock %}
{% block body %}
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page mb-8">Infrastructure</h1>
<div class="flex flex-wrap gap-6 mb-8">
{# Redis #}
<div class="flex-1 min-w-[400px]">
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">Redis</h2>
{% if redis.connected %}
<span class="admin-badge-green text-xs font-black uppercase">Connecte</span>
{% else %}
<span class="admin-badge-red text-xs font-black uppercase">Deconnecte</span>
{% endif %}
</div>
{% if redis.connected %}
<div class="flex flex-col gap-0">
{{ _self.infra_row('Version', redis.version) }}
{{ _self.infra_row('Uptime', redis.uptime_days ~ ' jours') }}
{{ _self.infra_row('Role', redis.role) }}
{{ _self.infra_row('Clients connectes', redis.connected_clients) }}
{{ _self.infra_row('Ops/sec', redis.instantaneous_ops_per_sec) }}
<div class="border-t border-gray-200 my-2"></div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Memoire</p>
{{ _self.infra_row('Utilisee', redis.used_memory_human) }}
{{ _self.infra_row('Pic', redis.used_memory_peak_human) }}
<div class="border-t border-gray-200 my-2"></div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Cache</p>
{{ _self.infra_row('Commandes traitees', redis.total_commands_processed|number_format(0, '.', ' ')) }}
{{ _self.infra_row('Hits', redis.keyspace_hits|number_format(0, '.', ' ')) }}
{{ _self.infra_row('Misses', redis.keyspace_misses|number_format(0, '.', ' ')) }}
{{ _self.infra_row_colored('Hit Rate', redis.hit_rate, redis.hit_rate != 'N/A' and redis.hit_rate|replace({'%': ''})|number_format > 80 ? 'green' : (redis.hit_rate == 'N/A' ? 'gray' : 'red')) }}
{{ _self.infra_row('Cles expirees', redis.expired_keys|number_format(0, '.', ' ')) }}
{{ _self.infra_row_colored('Cles evictees', redis.evicted_keys, redis.evicted_keys == '0' ? 'green' : 'red') }}
</div>
{% else %}
<p class="text-sm text-red-500 font-bold">{{ redis.error }}</p>
{% endif %}
</div>
</div>
{# PostgreSQL #}
<div class="flex-1 min-w-[400px]">
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">PostgreSQL</h2>
{% if postgres.connected %}
<span class="admin-badge-green text-xs font-black uppercase">Connecte</span>
{% else %}
<span class="admin-badge-red text-xs font-black uppercase">Deconnecte</span>
{% endif %}
</div>
{% if postgres.connected %}
<div class="flex flex-col gap-0">
{{ _self.infra_row('Version', postgres.version) }}
{{ _self.infra_row('Uptime', postgres.uptime) }}
{{ _self.infra_row('Taille BDD', postgres.db_size) }}
{{ _self.infra_row('Tables', postgres.table_count) }}
<div class="border-t border-gray-200 my-2"></div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Connexions</p>
{{ _self.infra_row('Actives', postgres.active_connections) }}
{{ _self.infra_row('Total', postgres.total_connections ~ ' / ' ~ postgres.max_connections) }}
<div class="border-t border-gray-200 my-2"></div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Performance</p>
{{ _self.infra_row_colored('Cache Hit Ratio', postgres.cache_hit_ratio, postgres.cache_hit_ratio != 'N/A' and postgres.cache_hit_ratio|replace({'%': ''})|number_format > 95 ? 'green' : (postgres.cache_hit_ratio == 'N/A' ? 'gray' : 'yellow')) }}
{{ _self.infra_row_colored('Dead Tuples', postgres.dead_tuples|number_format(0, '.', ' '), postgres.dead_tuples < 10000 ? 'green' : (postgres.dead_tuples < 100000 ? 'yellow' : 'red')) }}
</div>
{% else %}
<p class="text-sm text-red-500 font-bold">{{ postgres.error }}</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% macro infra_row(label, value) %}
<div class="flex justify-between py-1.5 text-sm">
<span class="text-gray-500 font-bold">{{ label }}</span>
<span class="font-black">{{ value }}</span>
</div>
{% endmacro %}
{% macro infra_row_colored(label, value, color) %}
<div class="flex justify-between py-1.5 text-sm">
<span class="text-gray-500 font-bold">{{ label }}</span>
<span class="font-black {% if color == 'green' %}text-green-600{% elseif color == 'red' %}text-red-600{% elseif color == 'yellow' %}text-yellow-600{% else %}text-gray-400{% endif %}">{{ value }}</span>
</div>
{% endmacro %}

View File

@@ -754,6 +754,28 @@ class AdminControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
}
public function testInfraPage(): void
{
$client = static::createClient();
$admin = $this->createUser(['ROLE_ROOT']);
$client->loginUser($admin);
$client->request('GET', '/admin/infra');
self::assertResponseIsSuccessful();
}
public function testInfraPageDeniedForNonRoot(): void
{
$client = static::createClient();
$user = $this->createUser();
$client->loginUser($user);
$client->request('GET', '/admin/infra');
self::assertResponseStatusCodeSame(403);
}
public function testInviteOrganizerPage(): void
{
$client = static::createClient();