Redesign admin Infra page: full-screen 4-column layout with Docker stats

Complete rewrite of /admin/infra into 4 columns:
- Col 1 (Serveur): CPU, RAM, Disk, System, Services (Caddy, Docker, SSL cert)
- Col 2 (Containers): All Docker containers with CPU%, RAM, state via Docker API
- Col 3 (Redis): Global stats + per-DB (Messenger, Sessions, Cache)
- Col 4 (PostgreSQL): Instance stats + PgBouncer pools/stats

Extract all infra logic into InfraService. Mount Docker socket (read-only)
in PHP container for container stats. Check SSL cert expiry and Caddy status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 10:51:04 +01:00
parent cb35efe3ff
commit 8db44017d2
5 changed files with 721 additions and 708 deletions

View File

@@ -10,6 +10,7 @@ services:
restart: unless-stopped
volumes:
- .:/app
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "4578-4579:9000"
networks:

View File

@@ -7,6 +7,7 @@ services:
restart: unless-stopped
volumes:
- .:/app
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "9000:9000"
depends_on:

View File

@@ -649,474 +649,9 @@ class AdminController extends AbstractController
}
#[Route('/infra', name: 'app_admin_infra', methods: ['GET'])]
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,
#[Autowire(env: 'DATABASE_URL')] string $databaseUrl,
): Response {
$redisGlobal = $this->getRedisGlobalInfo($messengerDsn);
$redisMessenger = $this->getRedisDbInfo($messengerDsn, 'Messenger');
$redisSession = $this->getRedisDbInfo($sessionDsn, 'Sessions');
$redisCache = $this->getRedisDbInfo($cacheDsn, 'Cache');
$pgInfo = $this->getPostgresInfo($em);
$pgbouncerInfo = $this->getPgBouncerInfo($databaseUrl);
$phpContainers = $this->getPhpContainerInfo();
$server = $this->getServerInfo();
return $this->render('admin/infra.html.twig', [
'server' => $server,
'redis_global' => $redisGlobal,
'redis_dbs' => [$redisMessenger, $redisSession, $redisCache],
'postgres' => $pgInfo,
'pgbouncer' => $pgbouncerInfo,
'php_containers' => $phpContainers,
]);
}
/**
* @return array<string, mixed>
*/
private function getServerInfo(): array
public function infra(\App\Service\InfraService $infra): Response
{
$info = [];
// CPU
$info['cpu_model'] = '?';
$info['cpu_cores'] = 1;
if (is_readable('/proc/cpuinfo')) {
$cpuinfo = file_get_contents('/proc/cpuinfo');
$info['cpu_cores'] = substr_count($cpuinfo, 'processor') ?: 1;
if (preg_match('/model name\s*:\s*(.+)/i', $cpuinfo, $m)) {
$info['cpu_model'] = trim($m[1]);
}
}
$load = sys_getloadavg();
$info['load_1m'] = $load ? round($load[0], 2) : '?';
$info['load_5m'] = $load ? round($load[1], 2) : '?';
$info['load_15m'] = $load ? round($load[2], 2) : '?';
$info['load_percent'] = $load ? round($load[0] / $info['cpu_cores'] * 100, 1) : '?';
// RAM from /proc/meminfo (host-level)
$info['ram_total'] = '?';
$info['ram_used'] = '?';
$info['ram_free'] = '?';
$info['ram_usage_percent'] = '?';
$info['ram_available'] = '?';
if (is_readable('/proc/meminfo')) {
$meminfo = file_get_contents('/proc/meminfo');
$values = [];
foreach (['MemTotal', 'MemFree', 'MemAvailable', 'Buffers', 'Cached'] as $key) {
if (preg_match("/{$key}:\s+(\d+)\s+kB/", $meminfo, $m)) {
$values[$key] = (int) $m[1] * 1024;
}
}
if (isset($values['MemTotal'])) {
$total = $values['MemTotal'];
$available = $values['MemAvailable'] ?? ($values['MemFree'] + ($values['Buffers'] ?? 0) + ($values['Cached'] ?? 0));
$used = $total - $available;
$info['ram_total'] = $this->formatBytes($total);
$info['ram_used'] = $this->formatBytes($used);
$info['ram_free'] = $this->formatBytes($available);
$info['ram_usage_percent'] = round($used / $total * 100, 1);
}
}
// Disk
$path = '/app';
$diskTotal = @disk_total_space($path);
$diskFree = @disk_free_space($path);
if (false !== $diskTotal && false !== $diskFree) {
$diskUsed = $diskTotal - $diskFree;
$info['disk_total'] = $this->formatBytes((int) $diskTotal);
$info['disk_used'] = $this->formatBytes((int) $diskUsed);
$info['disk_free'] = $this->formatBytes((int) $diskFree);
$info['disk_usage_percent'] = round($diskUsed / $diskTotal * 100, 1);
} else {
$info['disk_total'] = '?';
$info['disk_used'] = '?';
$info['disk_free'] = '?';
$info['disk_usage_percent'] = '?';
}
// Uptime & hostname
if (is_readable('/proc/uptime')) {
$info['uptime'] = $this->formatSeconds((int) explode(' ', file_get_contents('/proc/uptime'))[0]);
} else {
$info['uptime'] = '?';
}
$info['hostname'] = gethostname() ?: '?';
$info['os'] = php_uname('s').' '.php_uname('r');
return $info;
}
/**
* @return list<array<string, mixed>>
*/
private function getPhpContainerInfo(): array
{
$container = $this->readContainerStats();
$container['hostname'] = gethostname() ?: '?';
$container['php_version'] = \PHP_VERSION;
$container['sapi'] = \PHP_SAPI;
return [$container];
}
/**
* @return array<string, mixed>
*/
private function readContainerStats(): array
{
$stats = ['connected' => true];
// CPU load
$load = sys_getloadavg();
$stats['load_1m'] = $load ? round($load[0], 2) : '?';
$stats['load_5m'] = $load ? round($load[1], 2) : '?';
$stats['load_15m'] = $load ? round($load[2], 2) : '?';
// CPU cores
$stats['cpu_cores'] = 1;
if (is_readable('/proc/cpuinfo')) {
$cpuinfo = file_get_contents('/proc/cpuinfo');
$stats['cpu_cores'] = substr_count($cpuinfo, 'processor');
}
// CPU usage from cgroup
$stats['cpu_usage_percent'] = $this->readCgroupCpuPercent();
// Memory from cgroup v2
$memCurrent = $this->readCgroupFile('/sys/fs/cgroup/memory.current');
$memMax = $this->readCgroupFile('/sys/fs/cgroup/memory.max');
// Fallback to cgroup v1
if (null === $memCurrent) {
$memCurrent = $this->readCgroupFile('/sys/fs/cgroup/memory/memory.usage_in_bytes');
}
if (null === $memMax) {
$memMax = $this->readCgroupFile('/sys/fs/cgroup/memory/memory.limit_in_bytes');
}
if (null !== $memCurrent) {
$stats['ram_used'] = $this->formatBytes((int) $memCurrent);
$stats['ram_used_bytes'] = (int) $memCurrent;
} else {
$stats['ram_used'] = round(memory_get_usage(true) / 1048576, 1).' MB';
$stats['ram_used_bytes'] = memory_get_usage(true);
}
if (null !== $memMax && $memMax < 9_000_000_000_000_000_000) {
$stats['ram_total'] = $this->formatBytes((int) $memMax);
$stats['ram_total_bytes'] = (int) $memMax;
$stats['ram_usage_percent'] = round($stats['ram_used_bytes'] / $stats['ram_total_bytes'] * 100, 1);
$stats['ram_free'] = $this->formatBytes($stats['ram_total_bytes'] - $stats['ram_used_bytes']);
} else {
// No limit set, read system memory
$meminfo = is_readable('/proc/meminfo') ? file_get_contents('/proc/meminfo') : '';
if (preg_match('/MemTotal:\s+(\d+)\s+kB/', $meminfo, $m)) {
$totalBytes = (int) $m[1] * 1024;
$stats['ram_total'] = $this->formatBytes($totalBytes);
$stats['ram_total_bytes'] = $totalBytes;
$stats['ram_usage_percent'] = round($stats['ram_used_bytes'] / $totalBytes * 100, 1);
$stats['ram_free'] = $this->formatBytes($totalBytes - $stats['ram_used_bytes']);
} else {
$stats['ram_total'] = '?';
$stats['ram_usage_percent'] = '?';
$stats['ram_free'] = '?';
}
}
// Uptime
if (is_readable('/proc/uptime')) {
$uptime = (int) explode(' ', file_get_contents('/proc/uptime'))[0];
$stats['uptime'] = $this->formatSeconds($uptime);
} else {
$stats['uptime'] = '?';
}
return $stats;
}
private function readCgroupCpuPercent(): string
{
// cgroup v2
$usagePath = '/sys/fs/cgroup/cpu.stat';
if (is_readable($usagePath)) {
$content = file_get_contents($usagePath);
if (preg_match('/usage_usec\s+(\d+)/', $content, $m)) {
$usageUsec1 = (int) $m[1];
usleep(100_000); // 100ms sample
$content2 = file_get_contents($usagePath);
if (preg_match('/usage_usec\s+(\d+)/', $content2, $m2)) {
$usageUsec2 = (int) $m2[1];
$deltaUsec = $usageUsec2 - $usageUsec1;
$percent = round($deltaUsec / 100_000 * 100, 1); // delta / sample_time * 100
return $percent.'%';
}
}
}
return 'N/A';
}
private function readCgroupFile(string $path): ?int
{
if (!is_readable($path)) {
return null;
}
$val = trim(file_get_contents($path));
return is_numeric($val) ? (int) $val : null;
}
private function formatBytes(int $bytes): string
{
if ($bytes >= 1_073_741_824) {
return round($bytes / 1_073_741_824, 1).' GB';
}
if ($bytes >= 1_048_576) {
return round($bytes / 1_048_576, 1).' MB';
}
return round($bytes / 1024, 1).' KB';
}
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 getRedisGlobalInfo(string $dsn): array
{
try {
$redis = $this->connectRedis($dsn);
$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()];
}
}
/**
* @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);
$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()];
}
}
/**
* @return array<string, mixed>
*/
private function getPgBouncerInfo(string $databaseUrl): array
{
try {
$parsed = parse_url($databaseUrl);
$user = $parsed['user'] ?? 'app';
$pass = $parsed['pass'] ?? '';
$host = $parsed['host'] ?? 'pgbouncer';
$port = (int) ($parsed['port'] ?? 6432);
// If DATABASE_URL points to postgres directly, try pgbouncer host
if (6432 !== $port) {
$host = 'pgbouncer';
$port = 6432;
}
$pdo = new \PDO(
"pgsql:host={$host};port={$port};dbname=pgbouncer",
$user,
$pass,
[
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_EMULATE_PREPARES => true,
],
);
$pools = $pdo->query('SHOW POOLS')->fetchAll(\PDO::FETCH_ASSOC);
$stats = $pdo->query('SHOW STATS')->fetchAll(\PDO::FETCH_ASSOC);
$poolData = [];
foreach ($pools as $pool) {
if ('pgbouncer' === ($pool['database'] ?? '')) {
continue;
}
$poolData[] = [
'database' => $pool['database'] ?? '?',
'user' => $pool['user'] ?? '?',
'cl_active' => $pool['cl_active'] ?? 0,
'cl_waiting' => $pool['cl_waiting'] ?? 0,
'sv_active' => $pool['sv_active'] ?? 0,
'sv_idle' => $pool['sv_idle'] ?? 0,
'sv_used' => $pool['sv_used'] ?? 0,
'pool_mode' => $pool['pool_mode'] ?? '?',
];
}
$statsData = [];
foreach ($stats as $stat) {
if ('pgbouncer' === ($stat['database'] ?? '')) {
continue;
}
$statsData[] = [
'database' => $stat['database'] ?? '?',
'total_xact_count' => $stat['total_xact_count'] ?? 0,
'total_query_count' => $stat['total_query_count'] ?? 0,
'total_received' => $stat['total_received'] ?? 0,
'total_sent' => $stat['total_sent'] ?? 0,
'avg_xact_time' => $stat['avg_xact_time'] ?? 0,
'avg_query_time' => $stat['avg_query_time'] ?? 0,
];
}
return [
'connected' => true,
'pools' => $poolData,
'stats' => $statsData,
];
} catch (\Throwable $e) {
return ['connected' => false, 'error' => $e->getMessage()];
}
return $this->render('admin/infra.html.twig', $infra->getAll());
}
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]

View File

@@ -0,0 +1,495 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @codeCoverageIgnore Infrastructure monitoring — reads /proc, Docker socket, network
*/
class InfraService
{
public function __construct(
private EntityManagerInterface $em,
#[Autowire(env: 'MESSENGER_TRANSPORT_DSN')] private string $messengerDsn,
#[Autowire(env: 'SESSION_HANDLER_DSN')] private string $sessionDsn,
#[Autowire(env: 'REDIS_CACHE_DSN')] private string $cacheDsn,
#[Autowire(env: 'DATABASE_URL')] private string $databaseUrl,
#[Autowire(env: 'OUTSIDE_URL')] private string $outsideUrl,
) {
}
/**
* @return array<string, mixed>
*/
public function getAll(): array
{
return [
'server' => $this->getServerInfo(),
'containers' => $this->getDockerContainers(),
'redis_global' => $this->getRedisGlobalInfo(),
'redis_dbs' => [
$this->getRedisDbInfo($this->messengerDsn, 'Messenger'),
$this->getRedisDbInfo($this->sessionDsn, 'Sessions'),
$this->getRedisDbInfo($this->cacheDsn, 'Cache'),
],
'postgres' => $this->getPostgresInfo(),
'pgbouncer' => $this->getPgBouncerInfo(),
];
}
// ── Server ───────────────────────────────────────────────
/**
* @return array<string, mixed>
*/
private function getServerInfo(): array
{
$info = ['hostname' => gethostname() ?: '?', 'os' => php_uname('s').' '.php_uname('r')];
// CPU
$info['cpu_model'] = '?';
$info['cpu_cores'] = 1;
if (is_readable('/proc/cpuinfo')) {
$cpuinfo = file_get_contents('/proc/cpuinfo');
$info['cpu_cores'] = substr_count($cpuinfo, 'processor') ?: 1;
if (preg_match('/model name\s*:\s*(.+)/i', $cpuinfo, $m)) {
$info['cpu_model'] = trim($m[1]);
}
}
$load = sys_getloadavg();
$info['load_1m'] = $load ? round($load[0], 2) : '?';
$info['load_5m'] = $load ? round($load[1], 2) : '?';
$info['load_15m'] = $load ? round($load[2], 2) : '?';
$info['load_percent'] = $load ? round($load[0] / $info['cpu_cores'] * 100, 1) : '?';
// RAM
$info += $this->readHostMemory();
// Disk
$diskTotal = @disk_total_space('/app');
$diskFree = @disk_free_space('/app');
if (false !== $diskTotal && false !== $diskFree) {
$diskUsed = $diskTotal - $diskFree;
$info['disk_total'] = self::fmtBytes((int) $diskTotal);
$info['disk_used'] = self::fmtBytes((int) $diskUsed);
$info['disk_free'] = self::fmtBytes((int) $diskFree);
$info['disk_percent'] = round($diskUsed / $diskTotal * 100, 1);
} else {
$info['disk_total'] = $info['disk_used'] = $info['disk_free'] = '?';
$info['disk_percent'] = '?';
}
// Uptime
$info['uptime'] = is_readable('/proc/uptime')
? self::fmtDuration((int) explode(' ', file_get_contents('/proc/uptime'))[0])
: '?';
// Services
$info['caddy'] = $this->checkCaddy();
$info['docker'] = $this->checkDocker();
$info['ssl'] = $this->checkSsl();
return $info;
}
/**
* @return array<string, mixed>
*/
private function readHostMemory(): array
{
if (!is_readable('/proc/meminfo')) {
return ['ram_total' => '?', 'ram_used' => '?', 'ram_free' => '?', 'ram_percent' => '?'];
}
$meminfo = file_get_contents('/proc/meminfo');
$v = [];
foreach (['MemTotal', 'MemFree', 'MemAvailable', 'Buffers', 'Cached'] as $k) {
if (preg_match("/{$k}:\s+(\d+)\s+kB/", $meminfo, $m)) {
$v[$k] = (int) $m[1] * 1024;
}
}
if (!isset($v['MemTotal'])) {
return ['ram_total' => '?', 'ram_used' => '?', 'ram_free' => '?', 'ram_percent' => '?'];
}
$total = $v['MemTotal'];
$avail = $v['MemAvailable'] ?? ($v['MemFree'] + ($v['Buffers'] ?? 0) + ($v['Cached'] ?? 0));
$used = $total - $avail;
return [
'ram_total' => self::fmtBytes($total),
'ram_used' => self::fmtBytes($used),
'ram_free' => self::fmtBytes($avail),
'ram_percent' => round($used / $total * 100, 1),
];
}
/**
* @return array{status: string, info: string}
*/
private function checkCaddy(): array
{
try {
$ch = curl_init($this->outsideUrl);
curl_setopt_array($ch, [\CURLOPT_NOBODY => true, \CURLOPT_RETURNTRANSFER => true, \CURLOPT_TIMEOUT => 3, \CURLOPT_SSL_VERIFYPEER => false]);
curl_exec($ch);
$code = curl_getinfo($ch, \CURLINFO_HTTP_CODE);
curl_close($ch);
return $code > 0 ? ['status' => 'ok', 'info' => "HTTP {$code}"] : ['status' => 'error', 'info' => 'Pas de reponse'];
} catch (\Throwable $e) {
return ['status' => 'error', 'info' => $e->getMessage()];
}
}
/**
* @return array{status: string, info: string}
*/
private function checkDocker(): array
{
$data = $this->dockerApi('/info');
return $data ? ['status' => 'ok', 'info' => 'Containers: '.($data['ContainersRunning'] ?? '?').' running'] : ['status' => 'error', 'info' => 'Socket inaccessible'];
}
/**
* @return array{status: string, domain: string, issuer: string, valid_until: string, days_left: int|string}
*/
private function checkSsl(): array
{
try {
$parsed = parse_url($this->outsideUrl);
$host = $parsed['host'] ?? '';
$ctx = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]);
$client = @stream_socket_client("ssl://{$host}:443", $errno, $errstr, 3, \STREAM_CLIENT_CONNECT, $ctx);
if (!$client) {
return ['status' => 'error', 'domain' => $host, 'issuer' => '?', 'valid_until' => '?', 'days_left' => '?'];
}
$params = stream_context_get_params($client);
$cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']);
fclose($client);
$validTo = new \DateTimeImmutable('@'.($cert['validTo_time_t'] ?? 0));
$daysLeft = (int) (new \DateTimeImmutable())->diff($validTo)->format('%r%a');
$issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? '?';
return [
'status' => $daysLeft > 7 ? 'ok' : ($daysLeft > 0 ? 'warning' : 'error'),
'domain' => $host,
'issuer' => $issuer,
'valid_until' => $validTo->format('d/m/Y'),
'days_left' => $daysLeft,
];
} catch (\Throwable) {
$host = parse_url($this->outsideUrl, \PHP_URL_HOST) ?? '?';
return ['status' => 'error', 'domain' => $host, 'issuer' => '?', 'valid_until' => '?', 'days_left' => '?'];
}
}
// ── Docker containers ────────────────────────────────────
/**
* @return list<array<string, mixed>>
*/
private function getDockerContainers(): array
{
$containers = $this->dockerApi('/containers/json');
if (!$containers) {
return [];
}
$result = [];
foreach ($containers as $c) {
$name = ltrim(($c['Names'][0] ?? '?'), '/');
$stats = $this->dockerApi('/containers/'.($c['Id'] ?? '').'/stats?stream=false');
$cpu = $this->calcCpuPercent($stats);
$mem = $this->calcMemory($stats);
$result[] = [
'name' => $name,
'image' => $this->shortImage($c['Image'] ?? '?'),
'state' => $c['State'] ?? '?',
'status' => $c['Status'] ?? '?',
'cpu_percent' => $cpu,
'ram_used' => $mem['used'],
'ram_limit' => $mem['limit'],
'ram_percent' => $mem['percent'],
];
}
usort($result, fn ($a, $b) => $a['name'] <=> $b['name']);
return $result;
}
private function calcCpuPercent(?array $stats): string
{
if (!$stats) {
return '?';
}
$cpuDelta = ($stats['cpu_stats']['cpu_usage']['total_usage'] ?? 0) - ($stats['precpu_stats']['cpu_usage']['total_usage'] ?? 0);
$sysDelta = ($stats['cpu_stats']['system_cpu_usage'] ?? 0) - ($stats['precpu_stats']['system_cpu_usage'] ?? 0);
$cores = $stats['cpu_stats']['online_cpus'] ?? 1;
if ($sysDelta > 0 && $cpuDelta >= 0) {
return round($cpuDelta / $sysDelta * $cores * 100, 1).'%';
}
return '0%';
}
/**
* @return array{used: string, limit: string, percent: string}
*/
private function calcMemory(?array $stats): array
{
if (!$stats) {
return ['used' => '?', 'limit' => '?', 'percent' => '?'];
}
$used = $stats['memory_stats']['usage'] ?? 0;
$limit = $stats['memory_stats']['limit'] ?? 0;
$cache = $stats['memory_stats']['stats']['cache'] ?? 0;
$actualUsed = $used - $cache;
return [
'used' => self::fmtBytes($actualUsed > 0 ? $actualUsed : $used),
'limit' => $limit > 0 && $limit < 9_000_000_000_000_000_000 ? self::fmtBytes($limit) : 'No limit',
'percent' => $limit > 0 && $limit < 9_000_000_000_000_000_000 ? round($actualUsed / $limit * 100, 1).'%' : 'N/A',
];
}
private function shortImage(string $image): string
{
if (str_contains($image, 'sha256:')) {
return substr($image, 7, 12);
}
return str_contains($image, '/') ? substr($image, strrpos($image, '/') + 1) : $image;
}
/**
* @return array<string, mixed>|null
*/
private function dockerApi(string $path): ?array
{
$socket = '/var/run/docker.sock';
if (!file_exists($socket)) {
return null;
}
$ch = curl_init("http://localhost/v1.43{$path}");
curl_setopt_array($ch, [
\CURLOPT_UNIX_SOCKET_PATH => $socket,
\CURLOPT_RETURNTRANSFER => true,
\CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, \CURLINFO_HTTP_CODE);
curl_close($ch);
if (200 !== $code || false === $response) {
return null;
}
return json_decode($response, true);
}
// ── Redis ────────────────────────────────────────────────
/**
* @return array<string, mixed>
*/
public function getRedisGlobalInfo(): array
{
try {
$redis = $this->connectRedis($this->messengerDsn);
$info = $redis->info();
$redis->close();
$hits = (int) ($info['keyspace_hits'] ?? 0);
$misses = (int) ($info['keyspace_misses'] ?? 0);
$total = $hits + $misses;
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' => $hits,
'keyspace_misses' => $misses,
'hit_rate' => $total > 0 ? round($hits / $total * 100, 1).'%' : 'N/A',
'expired_keys' => $info['expired_keys'] ?? '0',
'evicted_keys' => $info['evicted_keys'] ?? '0',
'ops_per_sec' => $info['instantaneous_ops_per_sec'] ?? '?',
'role' => $info['role'] ?? '?',
];
} catch (\Throwable $e) {
return ['connected' => false, 'error' => $e->getMessage()];
}
}
/**
* @return array<string, mixed>
*/
private function getRedisDbInfo(string $dsn, string $label): array
{
try {
$redis = $this->connectRedis($dsn);
$dbSize = $redis->dbSize();
$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;
}
}
$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' => $withTtl > 0 ? self::fmtDuration((int) round($totalTtl / $withTtl)) : 'N/A',
];
} catch (\Throwable $e) {
return ['label' => $label, 'connected' => false, 'error' => $e->getMessage()];
}
}
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;
}
// ── PostgreSQL ───────────────────────────────────────────
/**
* @return array<string, mixed>
*/
private function getPostgresInfo(): array
{
try {
$conn = $this->em->getConnection();
return [
'connected' => true,
'version' => $conn->executeQuery('SHOW server_version')->fetchOne(),
'db_size' => $conn->executeQuery('SELECT pg_size_pretty(pg_database_size(current_database()))')->fetchOne(),
'active_connections' => $conn->executeQuery("SELECT count(*) FROM pg_stat_activity WHERE state = 'active'")->fetchOne(),
'total_connections' => $conn->executeQuery('SELECT count(*) FROM pg_stat_activity')->fetchOne(),
'max_connections' => $conn->executeQuery('SHOW max_connections')->fetchOne(),
'uptime' => $conn->executeQuery('SELECT now() - pg_postmaster_start_time()')->fetchOne(),
'cache_hit_ratio' => ($v = $conn->executeQuery('SELECT round(sum(blks_hit)::numeric / nullif(sum(blks_hit + blks_read), 0) * 100, 2) FROM pg_stat_database')->fetchOne()) ? $v.'%' : 'N/A',
'dead_tuples' => $conn->executeQuery('SELECT sum(n_dead_tup) FROM pg_stat_user_tables')->fetchOne() ?? '0',
'table_count' => $conn->executeQuery("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'")->fetchOne(),
];
} catch (\Throwable $e) {
return ['connected' => false, 'error' => $e->getMessage()];
}
}
/**
* @return array<string, mixed>
*/
private function getPgBouncerInfo(): array
{
try {
$parsed = parse_url($this->databaseUrl);
$user = $parsed['user'] ?? 'app';
$pass = $parsed['pass'] ?? '';
$host = $parsed['host'] ?? 'pgbouncer';
$port = (int) ($parsed['port'] ?? 6432);
if (6432 !== $port) {
$host = 'pgbouncer';
$port = 6432;
}
$pdo = new \PDO("pgsql:host={$host};port={$port};dbname=pgbouncer", $user, $pass, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_EMULATE_PREPARES => true,
]);
$pools = [];
foreach ($pdo->query('SHOW POOLS')->fetchAll(\PDO::FETCH_ASSOC) as $p) {
if ('pgbouncer' === ($p['database'] ?? '')) {
continue;
}
$pools[] = $p;
}
$stats = [];
foreach ($pdo->query('SHOW STATS')->fetchAll(\PDO::FETCH_ASSOC) as $s) {
if ('pgbouncer' === ($s['database'] ?? '')) {
continue;
}
$stats[] = $s;
}
return ['connected' => true, 'pools' => $pools, 'stats' => $stats];
} catch (\Throwable $e) {
return ['connected' => false, 'error' => $e->getMessage()];
}
}
// ── Helpers ──────────────────────────────────────────────
public static function fmtBytes(int $bytes): string
{
if ($bytes >= 1_073_741_824) {
return round($bytes / 1_073_741_824, 1).' GB';
}
if ($bytes >= 1_048_576) {
return round($bytes / 1_048_576, 1).' MB';
}
return round($bytes / 1024, 1).' KB';
}
public static function fmtDuration(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';
}
}

View File

@@ -3,296 +3,277 @@
{% block title %}Infrastructure{% endblock %}
{% block body %}
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page mb-8">Infrastructure</h1>
<div class="grid grid-cols-1 xl:grid-cols-4 gap-4 min-h-[calc(100vh-80px)]">
{# Server #}
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">Serveur</h2>
<div class="flex flex-wrap gap-6 mb-8">
{# CPU #}
<div class="flex-1 min-w-[280px]">
{# ── COL 1: Serveur ────────────────────────────────── #}
<div class="flex flex-col gap-4">
<h2 class="text-lg font-black uppercase tracking-tighter italic heading-page">Serveur</h2>
{# Systeme #}
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">CPU</h2>
<div class="flex flex-col gap-0">
{{ _self.row('Modele', server.cpu_model) }}
{{ _self.row('Coeurs', server.cpu_cores) }}
{{ _self.row('Load 1m / 5m / 15m', server.load_1m ~ ' / ' ~ server.load_5m ~ ' / ' ~ server.load_15m) }}
{{ _self.row_color('Charge CPU', server.load_percent != '?' ? server.load_percent ~ '%' : '?', server.load_percent != '?' and server.load_percent < 70 ? 'green' : (server.load_percent == '?' ? 'gray' : (server.load_percent < 90 ? 'yellow' : 'red'))) }}
</div>
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">Systeme</h3>
{{ _self.r('Hostname', server.hostname) }}
{{ _self.r('OS', server.os) }}
{{ _self.r('Uptime', server.uptime) }}
</div>
{# CPU #}
<div class="admin-card">
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">CPU</h3>
{{ _self.r('Modele', server.cpu_model) }}
{{ _self.r('Coeurs', server.cpu_cores) }}
{{ _self.r('Load 1/5/15m', server.load_1m ~ ' / ' ~ server.load_5m ~ ' / ' ~ server.load_15m) }}
{{ _self.c('Charge', server.load_percent != '?' ? server.load_percent ~ '%' : '?', _self.pct_color(server.load_percent)) }}
</div>
{# RAM #}
<div class="flex-1 min-w-[280px]">
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">RAM</h2>
<div class="flex flex-col gap-0">
{{ _self.row('Total', server.ram_total) }}
{{ _self.row('Utilisee', server.ram_used) }}
{{ _self.row('Disponible', server.ram_free) }}
{{ _self.row_color('Utilisation', server.ram_usage_percent != '?' ? server.ram_usage_percent ~ '%' : '?', server.ram_usage_percent != '?' and server.ram_usage_percent < 70 ? 'green' : (server.ram_usage_percent == '?' ? 'gray' : (server.ram_usage_percent < 90 ? 'yellow' : 'red'))) }}
</div>
</div>
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">RAM</h3>
{{ _self.r('Total', server.ram_total) }}
{{ _self.r('Utilisee', server.ram_used) }}
{{ _self.r('Disponible', server.ram_free) }}
{{ _self.c('Utilisation', server.ram_percent != '?' ? server.ram_percent ~ '%' : '?', _self.pct_color(server.ram_percent)) }}
</div>
{# Disk #}
<div class="flex-1 min-w-[280px]">
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Disque</h2>
<div class="flex flex-col gap-0">
{{ _self.row('Total', server.disk_total) }}
{{ _self.row('Utilise', server.disk_used) }}
{{ _self.row('Libre', server.disk_free) }}
{{ _self.row_color('Utilisation', server.disk_usage_percent != '?' ? server.disk_usage_percent ~ '%' : '?', server.disk_usage_percent != '?' and server.disk_usage_percent < 70 ? 'green' : (server.disk_usage_percent == '?' ? 'gray' : (server.disk_usage_percent < 90 ? 'yellow' : 'red'))) }}
</div>
</div>
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">Disque</h3>
{{ _self.r('Total', server.disk_total) }}
{{ _self.r('Utilise', server.disk_used) }}
{{ _self.r('Libre', server.disk_free) }}
{{ _self.c('Utilisation', server.disk_percent != '?' ? server.disk_percent ~ '%' : '?', _self.pct_color(server.disk_percent)) }}
</div>
{# System #}
<div class="flex-1 min-w-[280px]">
{# Services #}
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Systeme</h2>
<div class="flex flex-col gap-0">
{{ _self.row('Hostname', server.hostname) }}
{{ _self.row('OS', server.os) }}
{{ _self.row('Uptime', server.uptime) }}
</div>
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">Services</h3>
<div class="flex flex-col gap-2">
{{ _self.svc('Caddy', server.caddy.status, server.caddy.info) }}
{{ _self.svc('Docker', server.docker.status, server.docker.info) }}
{{ _self.svc_ssl(server.ssl) }}
</div>
</div>
</div>
{# PHP Containers #}
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">PHP</h2>
<div class="flex flex-wrap gap-6 mb-8">
{% for container in php_containers %}
<div class="flex-1 min-w-[300px]">
{# ── COL 2: Containers ─────────────────────────────── #}
<div class="flex flex-col gap-4">
<h2 class="text-lg font-black uppercase tracking-tighter italic heading-page">Containers</h2>
{% if containers|length > 0 %}
{% for c in containers %}
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">{{ container.hostname }}</h2>
<span class="admin-badge-green text-xs font-black uppercase">PHP {{ container.php_version }}</span>
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-black uppercase tracking-widest">{{ c.name }}</h3>
{% if c.state == 'running' %}
<span class="admin-badge-green text-[9px] font-black uppercase">{{ c.state }}</span>
{% else %}
<span class="admin-badge-red text-[9px] font-black uppercase">{{ c.state }}</span>
{% endif %}
</div>
<div class="flex flex-col gap-0">
{{ _self.row('SAPI', container.sapi) }}
{{ _self.row('Uptime', container.uptime) }}
{{ _self.row('CPU Cores', container.cpu_cores) }}
<div class="border-t border-gray-200 my-2"></div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">CPU</p>
{{ _self.row_color('Utilisation', container.cpu_usage_percent, container.cpu_usage_percent != 'N/A' and container.cpu_usage_percent|replace({'%': ''})|number_format < 70 ? 'green' : (container.cpu_usage_percent == 'N/A' ? 'gray' : (container.cpu_usage_percent|replace({'%': ''})|number_format < 90 ? 'yellow' : 'red'))) }}
{{ _self.row('Load 1m / 5m / 15m', container.load_1m ~ ' / ' ~ container.load_5m ~ ' / ' ~ container.load_15m) }}
<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.row('Utilisee', container.ram_used) }}
{{ _self.row('Total', container.ram_total) }}
{{ _self.row('Libre', container.ram_free) }}
{{ _self.row_color('Utilisation', container.ram_usage_percent ~ (container.ram_usage_percent != '?' ? '%' : ''), container.ram_usage_percent != '?' and container.ram_usage_percent < 70 ? 'green' : (container.ram_usage_percent == '?' ? 'gray' : (container.ram_usage_percent < 90 ? 'yellow' : 'red'))) }}
<p class="text-[10px] text-gray-400 mb-2">{{ c.image }}{{ c.status }}</p>
<div class="grid grid-cols-3 gap-2 text-center">
<div>
<p class="text-[9px] font-black uppercase text-gray-400">CPU</p>
<p class="text-sm font-black {{ _self.pct_class(c.cpu_percent|replace({'%': ''})) }}">{{ c.cpu_percent }}</p>
</div>
<div>
<p class="text-[9px] font-black uppercase text-gray-400">RAM</p>
<p class="text-sm font-black">{{ c.ram_used }}</p>
</div>
<div>
<p class="text-[9px] font-black uppercase text-gray-400">RAM %</p>
<p class="text-sm font-black {{ _self.pct_class(c.ram_percent|replace({'%': '', 'N/A': '0'})) }}">{{ c.ram_percent }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
{# PostgreSQL & PgBouncer #}
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">PostgreSQL & PgBouncer</h2>
<div class="flex flex-wrap gap-6 mb-8">
{# PostgreSQL #}
<div class="flex-1 min-w-[350px]">
{% else %}
<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.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.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.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>
{% endif %}
</div>
</div>
{# PgBouncer Pools #}
{% if pgbouncer.connected %}
<div class="flex-1 min-w-[350px]">
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">PgBouncer — Pools</h2>
{% if pgbouncer.pools|length > 0 %}
<div class="overflow-x-auto">
<table class="admin-table w-full">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Database</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Mode</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Cl. actifs</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Cl. attente</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Sv. actifs</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Sv. idle</th>
</tr>
</thead>
<tbody>
{% for pool in pgbouncer.pools %}
<tr>
<td class="font-black text-sm">{{ pool.database }}</td>
<td class="text-center"><span class="admin-badge-green text-xs font-black uppercase">{{ pool.pool_mode }}</span></td>
<td class="text-center font-black">{{ pool.cl_active }}</td>
<td class="text-center font-black {% if pool.cl_waiting > 0 %}text-yellow-600{% endif %}">{{ pool.cl_waiting }}</td>
<td class="text-center font-black">{{ pool.sv_active }}</td>
<td class="text-center font-black text-gray-400">{{ pool.sv_idle }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400">Aucun pool actif.</p>
{% endif %}
</div>
</div>
{# PgBouncer Stats #}
<div class="flex-1 min-w-[350px]">
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">PgBouncer — Stats</h2>
{% if pgbouncer.stats|length > 0 %}
<div class="overflow-x-auto">
<table class="admin-table w-full">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Database</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Xacts</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Queries</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Avg xact</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Avg query</th>
</tr>
</thead>
<tbody>
{% for stat in pgbouncer.stats %}
<tr>
<td class="font-black text-sm">{{ stat.database }}</td>
<td class="text-right text-sm font-bold">{{ stat.total_xact_count|number_format(0, '.', ' ') }}</td>
<td class="text-right text-sm font-bold">{{ stat.total_query_count|number_format(0, '.', ' ') }}</td>
<td class="text-right text-sm {% if stat.avg_xact_time > 100000 %}text-red-600{% elseif stat.avg_xact_time > 10000 %}text-yellow-600{% else %}text-green-600{% endif %} font-bold">{{ stat.avg_xact_time|number_format(0, '.', ' ') }}us</td>
<td class="text-right text-sm {% if stat.avg_query_time > 50000 %}text-red-600{% elseif stat.avg_query_time > 5000 %}text-yellow-600{% else %}text-green-600{% endif %} font-bold">{{ stat.avg_query_time|number_format(0, '.', ' ') }}us</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400">Aucune stat disponible.</p>
{% endif %}
</div>
</div>
{% else %}
<div class="flex-1 min-w-[350px]">
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">PgBouncer</h2>
<span class="admin-badge-red text-xs font-black uppercase">Deconnecte</span>
</div>
<p class="text-sm text-red-500 font-bold">{{ pgbouncer.error }}</p>
</div>
<p class="text-sm text-gray-400">Docker socket inaccessible.</p>
</div>
{% endif %}
</div>
{# Redis #}
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">Redis</h2>
<div class="flex flex-wrap gap-6 mb-8">
{# ── COL 3: Redis ──────────────────────────────────── #}
<div class="flex flex-col gap-4">
<h2 class="text-lg font-black uppercase tracking-tighter italic heading-page">Redis</h2>
{# Global #}
<div class="flex-1 min-w-[350px]">
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">Global</h2>
<div class="flex items-center justify-between mb-3">
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400">Global</h3>
{% if redis_global.connected %}
<span class="admin-badge-green text-xs font-black uppercase">Connecte</span>
<span class="admin-badge-green text-[9px] font-black uppercase">v{{ redis_global.version }}</span>
{% else %}
<span class="admin-badge-red text-xs font-black uppercase">Deconnecte</span>
<span class="admin-badge-red text-[9px] font-black uppercase">Deconnecte</span>
{% endif %}
</div>
{% if redis_global.connected %}
<div class="flex flex-col gap-0">
{{ _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.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">Performance</p>
{{ _self.row('Commandes', redis_global.total_commands_processed|number_format(0, '.', ' ')) }}
{{ _self.row('Hits / Misses', redis_global.keyspace_hits|number_format(0, '.', ' ') ~ ' / ' ~ 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_color('Cles evictees', redis_global.evicted_keys, redis_global.evicted_keys == '0' ? 'green' : 'red') }}
</div>
{{ _self.r('Uptime', redis_global.uptime_days ~ 'j') }}
{{ _self.r('Role', redis_global.role) }}
{{ _self.r('Clients', redis_global.connected_clients) }}
{{ _self.r('Ops/sec', redis_global.ops_per_sec) }}
<div class="border-t border-gray-200 my-1"></div>
{{ _self.r('Memoire', redis_global.used_memory_human ~ ' / pic ' ~ redis_global.used_memory_peak_human) }}
<div class="border-t border-gray-200 my-1"></div>
{{ _self.r('Commandes', redis_global.total_commands_processed|number_format(0, '.', ' ')) }}
{{ _self.r('Hits / Misses', redis_global.keyspace_hits|number_format(0, '.', ' ') ~ ' / ' ~ redis_global.keyspace_misses|number_format(0, '.', ' ')) }}
{{ _self.c('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.c('Evictees', redis_global.evicted_keys, redis_global.evicted_keys == '0' ? 'green' : 'red') }}
{% else %}
<p class="text-sm text-red-500 font-bold">{{ redis_global.error }}</p>
{% endif %}
</div>
</div>
{# Per-DB cards #}
{# Per-DB #}
{% for db in redis_dbs %}
<div class="flex-1 min-w-[220px]">
<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>
<div class="flex items-center justify-between mb-2">
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400">{{ db.label }}</h3>
{% if db.connected %}
<span class="admin-badge-green text-xs font-black uppercase">{{ db.db }}</span>
<span class="admin-badge-green text-[9px] font-black uppercase">{{ db.db }}</span>
{% else %}
<span class="admin-badge-red text-xs font-black uppercase">Erreur</span>
<span class="admin-badge-red text-[9px] 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>
{{ _self.r('Cles', db.keys|number_format(0, '.', ' ')) }}
{{ _self.r('TTL moyen', db.avg_ttl) }}
{% else %}
<p class="text-sm text-red-500 font-bold">{{ db.error }}</p>
<p class="text-xs text-red-500 font-bold">{{ db.error }}</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{# ── COL 4: PostgreSQL & PgBouncer ─────────────────── #}
<div class="flex flex-col gap-4">
<h2 class="text-lg font-black uppercase tracking-tighter italic heading-page">PostgreSQL</h2>
{# PostgreSQL #}
<div class="admin-card">
<div class="flex items-center justify-between mb-3">
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400">Instance</h3>
{% if postgres.connected %}
<span class="admin-badge-green text-[9px] font-black uppercase">v{{ postgres.version }}</span>
{% else %}
<span class="admin-badge-red text-[9px] font-black uppercase">Deconnecte</span>
{% endif %}
</div>
{% if postgres.connected %}
{{ _self.r('Uptime', postgres.uptime) }}
{{ _self.r('Taille BDD', postgres.db_size) }}
{{ _self.r('Tables', postgres.table_count) }}
<div class="border-t border-gray-200 my-1"></div>
{{ _self.r('Connexions actives', postgres.active_connections) }}
{{ _self.r('Connexions total', postgres.total_connections ~ ' / ' ~ postgres.max_connections) }}
<div class="border-t border-gray-200 my-1"></div>
{{ _self.c('Cache Hit', 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.c('Dead Tuples', postgres.dead_tuples|number_format(0, '.', ' '), postgres.dead_tuples < 10000 ? 'green' : (postgres.dead_tuples < 100000 ? 'yellow' : 'red')) }}
{% else %}
<p class="text-sm text-red-500 font-bold">{{ postgres.error }}</p>
{% endif %}
</div>
{# PgBouncer #}
<div class="admin-card">
<div class="flex items-center justify-between mb-3">
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400">PgBouncer</h3>
{% if pgbouncer.connected %}
<span class="admin-badge-green text-[9px] font-black uppercase">Connecte</span>
{% else %}
<span class="admin-badge-red text-[9px] font-black uppercase">Deconnecte</span>
{% endif %}
</div>
{% if pgbouncer.connected %}
{% if pgbouncer.pools|length > 0 %}
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Pools</p>
{% for pool in pgbouncer.pools %}
<div class="bg-gray-50 p-2 mb-2 border border-gray-200">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-black">{{ pool.database }}</span>
<span class="text-[9px] font-bold text-gray-400 uppercase">{{ pool.pool_mode }}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-[11px]">
{{ _self.r('Cl. actifs', pool.cl_active) }}
<div class="flex justify-between"><span class="text-gray-500 font-bold">Cl. attente</span><span class="font-black {% if pool.cl_waiting > 0 %}text-yellow-600{% endif %}">{{ pool.cl_waiting }}</span></div>
{{ _self.r('Sv. actifs', pool.sv_active) }}
{{ _self.r('Sv. idle', pool.sv_idle) }}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-xs text-gray-400 mb-2">Aucun pool actif.</p>
{% endif %}
{% if pgbouncer.stats|length > 0 %}
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2 mt-3">Stats</p>
{% for stat in pgbouncer.stats %}
<div class="bg-gray-50 p-2 mb-2 border border-gray-200">
<p class="text-xs font-black mb-1">{{ stat.database }}</p>
<div class="text-[11px]">
{{ _self.r('Transactions', stat.total_xact_count|number_format(0, '.', ' ')) }}
{{ _self.r('Requetes', stat.total_query_count|number_format(0, '.', ' ')) }}
{{ _self.c('Avg xact', stat.avg_xact_time|number_format(0, '.', ' ') ~ 'us', stat.avg_xact_time > 100000 ? 'red' : (stat.avg_xact_time > 10000 ? 'yellow' : 'green')) }}
{{ _self.c('Avg query', stat.avg_query_time|number_format(0, '.', ' ') ~ 'us', stat.avg_query_time > 50000 ? 'red' : (stat.avg_query_time > 5000 ? 'yellow' : 'green')) }}
</div>
</div>
{% endfor %}
{% endif %}
{% else %}
<p class="text-sm text-red-500 font-bold">{{ pgbouncer.error }}</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% macro row(label, value) %}
<div class="flex justify-between py-1.5 text-sm">
{# ── Macros ──────────────────────────────────────────── #}
{% macro r(label, value) %}
<div class="flex justify-between py-0.5 text-[12px]">
<span class="text-gray-500 font-bold">{{ label }}</span>
<span class="font-black">{{ value }}</span>
</div>
{% endmacro %}
{% macro row_color(label, value, color) %}
<div class="flex justify-between py-1.5 text-sm">
{% macro c(label, value, color) %}
<div class="flex justify-between py-0.5 text-[12px]">
<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 %}
{% macro svc(name, status, info) %}
<div class="flex items-center justify-between py-0.5 text-[12px]">
<span class="font-bold text-gray-500">{{ name }}</span>
<div class="flex items-center gap-2">
<span class="text-[11px] text-gray-400">{{ info }}</span>
{% if status == 'ok' %}
<span class="w-2 h-2 rounded-full bg-green-500 inline-block"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-red-500 inline-block"></span>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro svc_ssl(ssl) %}
<div class="flex items-center justify-between py-0.5 text-[12px]">
<span class="font-bold text-gray-500">SSL {{ ssl.domain }}</span>
<div class="flex items-center gap-2">
<span class="text-[11px] text-gray-400">{{ ssl.issuer }}{{ ssl.valid_until }} ({{ ssl.days_left }}j)</span>
{% if ssl.status == 'ok' %}
<span class="w-2 h-2 rounded-full bg-green-500 inline-block"></span>
{% elseif ssl.status == 'warning' %}
<span class="w-2 h-2 rounded-full bg-yellow-500 inline-block"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-red-500 inline-block"></span>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro pct_color(val) %}{% if val != '?' and val < 70 %}green{% elseif val == '?' %}gray{% elseif val < 90 %}yellow{% else %}red{% endif %}{% endmacro %}
{% macro pct_class(val) %}{% if val != '?' and val < 70 %}text-green-600{% elseif val == '?' %}text-gray-400{% elseif val < 90 %}text-yellow-600{% else %}text-red-600{% endif %}{% endmacro %}