From 58301840a6fae5d786bb06c8e53147dc9181a195 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 26 Mar 2026 10:27:15 +0100 Subject: [PATCH] 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) --- src/Controller/AdminController.php | 102 +++++++++++++++++++++++ templates/admin/base.html.twig | 1 + templates/admin/infra.html.twig | 98 ++++++++++++++++++++++ tests/Controller/AdminControllerTest.php | 22 +++++ 4 files changed, 223 insertions(+) create mode 100644 templates/admin/infra.html.twig diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 615712f..a526476 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -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 + */ + 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 + */ + 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 { diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 8ef0604..55ef5b7 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -24,6 +24,7 @@ Evenements Commandes Logs + Infra
diff --git a/templates/admin/infra.html.twig b/templates/admin/infra.html.twig new file mode 100644 index 0000000..ab976c1 --- /dev/null +++ b/templates/admin/infra.html.twig @@ -0,0 +1,98 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}Infrastructure{% endblock %} + +{% block body %} +

Infrastructure

+ +
+ {# Redis #} +
+
+
+

Redis

+ {% if redis.connected %} + Connecte + {% else %} + Deconnecte + {% endif %} +
+ + {% if redis.connected %} +
+ {{ _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) }} + +
+

Memoire

+ {{ _self.infra_row('Utilisee', redis.used_memory_human) }} + {{ _self.infra_row('Pic', redis.used_memory_peak_human) }} + +
+

Cache

+ {{ _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') }} +
+ {% else %} +

{{ redis.error }}

+ {% endif %} +
+
+ + {# PostgreSQL #} +
+
+
+

PostgreSQL

+ {% if postgres.connected %} + Connecte + {% else %} + Deconnecte + {% endif %} +
+ + {% if postgres.connected %} +
+ {{ _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) }} + +
+

Connexions

+ {{ _self.infra_row('Actives', postgres.active_connections) }} + {{ _self.infra_row('Total', postgres.total_connections ~ ' / ' ~ postgres.max_connections) }} + +
+

Performance

+ {{ _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')) }} +
+ {% else %} +

{{ postgres.error }}

+ {% endif %} +
+
+
+{% endblock %} + +{% macro infra_row(label, value) %} +
+ {{ label }} + {{ value }} +
+{% endmacro %} + +{% macro infra_row_colored(label, value, color) %} +
+ {{ label }} + {{ value }} +
+{% endmacro %} diff --git a/tests/Controller/AdminControllerTest.php b/tests/Controller/AdminControllerTest.php index 454e367..b4e0ff5 100644 --- a/tests/Controller/AdminControllerTest.php +++ b/tests/Controller/AdminControllerTest.php @@ -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();