Split admin Infra page into Redis global + per-database stats

Shows 3 Redis databases separately (Messenger, Sessions, Cache) with
key count and average TTL, alongside global Redis stats and PostgreSQL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 10:28:37 +01:00
parent 58301840a6
commit 1a336edac5
2 changed files with 154 additions and 50 deletions

View File

@@ -649,39 +649,58 @@ 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);
public function infra(
EntityManagerInterface $em,
#[Autowire(env: 'MESSENGER_TRANSPORT_DSN')] string $messengerDsn,
#[Autowire(env: 'SESSION_HANDLER_DSN')] string $sessionDsn,
#[Autowire(env: 'REDIS_CACHE_DSN')] string $cacheDsn,
): Response {
$redisGlobal = $this->getRedisGlobalInfo($messengerDsn);
$redisMessenger = $this->getRedisDbInfo($messengerDsn, 'Messenger');
$redisSession = $this->getRedisDbInfo($sessionDsn, 'Sessions');
$redisCache = $this->getRedisDbInfo($cacheDsn, 'Cache');
$pgInfo = $this->getPostgresInfo($em);
return $this->render('admin/infra.html.twig', [
'redis' => $redisInfo,
'redis_global' => $redisGlobal,
'redis_dbs' => [$redisMessenger, $redisSession, $redisCache],
'postgres' => $pgInfo,
]);
}
private function connectRedis(string $dsn): \Redis
{
$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);
}
$path = trim($parsed['path'] ?? '', '/');
if ('' !== $path && is_numeric($path)) {
$redis->select((int) $path);
}
return $redis;
}
/**
* @return array<string, mixed>
*/
private function getRedisInfo(string $dsn): array
private function getRedisGlobalInfo(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);
}
$redis = $this->connectRedis($dsn);
$info = $redis->info();
$redis->close();
@@ -706,6 +725,63 @@ class AdminController extends AbstractController
}
}
/**
* @return array<string, mixed>
*/
private function getRedisDbInfo(string $dsn, string $label): array
{
try {
$redis = $this->connectRedis($dsn);
$dbSize = $redis->dbSize();
$ttlSample = null;
$keys = $redis->keys('*');
$withTtl = 0;
$totalTtl = 0;
$sampleSize = min(\count($keys), 50);
for ($i = 0; $i < $sampleSize; ++$i) {
$ttl = $redis->ttl($keys[$i]);
if ($ttl > 0) {
++$withTtl;
$totalTtl += $ttl;
}
}
if ($withTtl > 0) {
$ttlSample = round($totalTtl / $withTtl);
}
$redis->close();
$parsed = parse_url(str_replace('redis://', 'http://', $dsn));
$path = trim($parsed['path'] ?? '', '/');
return [
'label' => $label,
'db' => is_numeric($path) ? 'DB '.$path : $path,
'connected' => true,
'keys' => $dbSize,
'avg_ttl' => $ttlSample ? $this->formatSeconds($ttlSample) : 'N/A',
];
} catch (\Throwable $e) {
return ['label' => $label, 'connected' => false, 'error' => $e->getMessage()];
}
}
private function formatSeconds(int $seconds): string
{
if ($seconds >= 86400) {
return round($seconds / 86400, 1).'j';
}
if ($seconds >= 3600) {
return round($seconds / 3600, 1).'h';
}
if ($seconds >= 60) {
return round($seconds / 60).'min';
}
return $seconds.'s';
}
private function computeHitRate(array $info): string
{
$hits = (int) ($info['keyspace_hits'] ?? 0);

View File

@@ -5,43 +5,43 @@
{% block body %}
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page mb-8">Infrastructure</h1>
{# Redis Global #}
<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 %}
<h2 class="text-sm font-black uppercase tracking-widest">Redis — Global</h2>
{% if redis_global.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 %}
{% if redis_global.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) }}
{{ _self.row('Version', redis_global.version) }}
{{ _self.row('Uptime', redis_global.uptime_days ~ ' jours') }}
{{ _self.row('Role', redis_global.role) }}
{{ _self.row('Clients connectes', redis_global.connected_clients) }}
{{ _self.row('Ops/sec', redis_global.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) }}
{{ _self.row('Utilisee', redis_global.used_memory_human) }}
{{ _self.row('Pic', redis_global.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') }}
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Performance</p>
{{ _self.row('Commandes traitees', redis_global.total_commands_processed|number_format(0, '.', ' ')) }}
{{ _self.row('Hits', redis_global.keyspace_hits|number_format(0, '.', ' ')) }}
{{ _self.row('Misses', redis_global.keyspace_misses|number_format(0, '.', ' ')) }}
{{ _self.row_color('Hit Rate', redis_global.hit_rate, redis_global.hit_rate != 'N/A' and redis_global.hit_rate|replace({'%': ''})|number_format > 80 ? 'green' : (redis_global.hit_rate == 'N/A' ? 'gray' : 'red')) }}
{{ _self.row('Cles expirees', redis_global.expired_keys|number_format(0, '.', ' ')) }}
{{ _self.row_color('Cles evictees', redis_global.evicted_keys, redis_global.evicted_keys == '0' ? 'green' : 'red') }}
</div>
{% else %}
<p class="text-sm text-red-500 font-bold">{{ redis.error }}</p>
<p class="text-sm text-red-500 font-bold">{{ redis_global.error }}</p>
{% endif %}
</div>
</div>
@@ -60,20 +60,20 @@
{% 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) }}
{{ _self.row('Version', postgres.version) }}
{{ _self.row('Uptime', postgres.uptime) }}
{{ _self.row('Taille BDD', postgres.db_size) }}
{{ _self.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) }}
{{ _self.row('Actives', postgres.active_connections) }}
{{ _self.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')) }}
{{ _self.row_color('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.row_color('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>
@@ -81,16 +81,44 @@
</div>
</div>
</div>
{# Redis DBs #}
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">Redis — Databases</h2>
<div class="flex flex-wrap gap-6 mb-8">
{% for db in redis_dbs %}
<div class="flex-1 min-w-[250px]">
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">{{ db.label }}</h2>
{% if db.connected %}
<span class="admin-badge-green text-xs font-black uppercase">{{ db.db }}</span>
{% else %}
<span class="admin-badge-red text-xs font-black uppercase">Erreur</span>
{% endif %}
</div>
{% if db.connected %}
<div class="flex flex-col gap-0">
{{ _self.row('Cles', db.keys|number_format(0, '.', ' ')) }}
{{ _self.row('TTL moyen', db.avg_ttl) }}
</div>
{% else %}
<p class="text-sm text-red-500 font-bold">{{ db.error }}</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% macro infra_row(label, value) %}
{% macro 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) %}
{% macro row_color(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>