From 8db44017d2cec3db675b15856a358be81e8ecf9e Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 26 Mar 2026 10:51:04 +0100 Subject: [PATCH] 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) --- ansible/docker-compose-prod.yml.j2 | 1 + docker-compose-dev.yml | 1 + src/Controller/AdminController.php | 469 +-------------------------- src/Service/InfraService.php | 495 +++++++++++++++++++++++++++++ templates/admin/infra.html.twig | 463 +++++++++++++-------------- 5 files changed, 721 insertions(+), 708 deletions(-) create mode 100644 src/Service/InfraService.php diff --git a/ansible/docker-compose-prod.yml.j2 b/ansible/docker-compose-prod.yml.j2 index 81d6494..c1d82aa 100644 --- a/ansible/docker-compose-prod.yml.j2 +++ b/ansible/docker-compose-prod.yml.j2 @@ -10,6 +10,7 @@ services: restart: unless-stopped volumes: - .:/app + - /var/run/docker.sock:/var/run/docker.sock:ro ports: - "4578-4579:9000" networks: diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 8359f0a..5a00627 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -7,6 +7,7 @@ services: restart: unless-stopped volumes: - .:/app + - /var/run/docker.sock:/var/run/docker.sock:ro ports: - "9000:9000" depends_on: diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index f1fd4f0..0953805 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -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 - */ - 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> - */ - private function getPhpContainerInfo(): array - { - $container = $this->readContainerStats(); - $container['hostname'] = gethostname() ?: '?'; - $container['php_version'] = \PHP_VERSION; - $container['sapi'] = \PHP_SAPI; - - return [$container]; - } - - /** - * @return array - */ - 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 - */ - 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 - */ - 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 - */ - 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 - */ - 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'])] diff --git a/src/Service/InfraService.php b/src/Service/InfraService.php new file mode 100644 index 0000000..85df301 --- /dev/null +++ b/src/Service/InfraService.php @@ -0,0 +1,495 @@ + + */ + 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 + */ + 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 + */ + 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> + */ + 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|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 + */ + 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 + */ + 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 + */ + 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 + */ + 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'; + } +} diff --git a/templates/admin/infra.html.twig b/templates/admin/infra.html.twig index a5290c4..5ce320f 100644 --- a/templates/admin/infra.html.twig +++ b/templates/admin/infra.html.twig @@ -3,296 +3,277 @@ {% block title %}Infrastructure{% endblock %} {% block body %} -

Infrastructure

+
+ + {# ── COL 1: Serveur ────────────────────────────────── #} +
+

Serveur

+ + {# Systeme #} +
+

Systeme

+ {{ _self.r('Hostname', server.hostname) }} + {{ _self.r('OS', server.os) }} + {{ _self.r('Uptime', server.uptime) }} +
- {# Server #} -

Serveur

-
{# CPU #} -
-
-

CPU

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

CPU

+ {{ _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)) }}
{# RAM #} -
-
-

RAM

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

RAM

+ {{ _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)) }}
{# Disk #} -
-
-

Disque

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

Disque

+ {{ _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)) }}
- {# System #} -
-
-

Systeme

-
- {{ _self.row('Hostname', server.hostname) }} - {{ _self.row('OS', server.os) }} - {{ _self.row('Uptime', server.uptime) }} -
+ {# Services #} +
+

Services

+
+ {{ _self.svc('Caddy', server.caddy.status, server.caddy.info) }} + {{ _self.svc('Docker', server.docker.status, server.docker.info) }} + {{ _self.svc_ssl(server.ssl) }}
- {# PHP Containers #} -

PHP

-
- {% for container in php_containers %} -
+ {# ── COL 2: Containers ─────────────────────────────── #} +
+

Containers

+ + {% if containers|length > 0 %} + {% for c in containers %}
-
-

{{ container.hostname }}

- PHP {{ container.php_version }} -
-
- {{ _self.row('SAPI', container.sapi) }} - {{ _self.row('Uptime', container.uptime) }} - {{ _self.row('CPU Cores', container.cpu_cores) }} - -
-

CPU

- {{ _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) }} - -
-

Memoire

- {{ _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'))) }} -
-
-
- {% endfor %} -
- - {# PostgreSQL & PgBouncer #} -

PostgreSQL & PgBouncer

-
- {# PostgreSQL #} -
-
-
-

PostgreSQL

- {% if postgres.connected %} - Connecte +
+

{{ c.name }}

+ {% if c.state == 'running' %} + {{ c.state }} {% else %} - Deconnecte + {{ c.state }} {% endif %}
- - {% if postgres.connected %} -
- {{ _self.row('Version', postgres.version) }} - {{ _self.row('Uptime', postgres.uptime) }} - {{ _self.row('Taille BDD', postgres.db_size) }} - {{ _self.row('Tables', postgres.table_count) }} - -
-

Connexions

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

Performance

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

{{ c.image }} — {{ c.status }}

+
+
+

CPU

+

{{ c.cpu_percent }}

+
+
+

RAM

+

{{ c.ram_used }}

+
+
+

RAM %

+

{{ c.ram_percent }}

+
- {% else %} -

{{ postgres.error }}

- {% endif %}
-
- - {# PgBouncer Pools #} - {% if pgbouncer.connected %} -
-
-

PgBouncer — Pools

- {% if pgbouncer.pools|length > 0 %} -
- - - - - - - - - - - - - {% for pool in pgbouncer.pools %} - - - - - - - - - {% endfor %} - -
DatabaseModeCl. actifsCl. attenteSv. actifsSv. idle
{{ pool.database }}{{ pool.pool_mode }}{{ pool.cl_active }}{{ pool.cl_waiting }}{{ pool.sv_active }}{{ pool.sv_idle }}
-
- {% else %} -

Aucun pool actif.

- {% endif %} -
-
- - {# PgBouncer Stats #} -
-
-

PgBouncer — Stats

- {% if pgbouncer.stats|length > 0 %} -
- - - - - - - - - - - - {% for stat in pgbouncer.stats %} - - - - - - - - {% endfor %} - -
DatabaseXactsQueriesAvg xactAvg query
{{ stat.database }}{{ stat.total_xact_count|number_format(0, '.', ' ') }}{{ stat.total_query_count|number_format(0, '.', ' ') }}{{ stat.avg_xact_time|number_format(0, '.', ' ') }}us{{ stat.avg_query_time|number_format(0, '.', ' ') }}us
-
- {% else %} -

Aucune stat disponible.

- {% endif %} -
-
+ {% endfor %} {% else %} -
-
-

PgBouncer

- Deconnecte -
-

{{ pgbouncer.error }}

+

Docker socket inaccessible.

-
{% endif %}
- {# Redis #} -

Redis

-
+ {# ── COL 3: Redis ──────────────────────────────────── #} +
+

Redis

+ {# Global #} -
-
-
-

Global

- {% if redis_global.connected %} - Connecte - {% else %} - Deconnecte - {% endif %} -
- +
+
+

Global

{% if redis_global.connected %} -
- {{ _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) }} - -
-

Memoire

- {{ _self.row('Utilisee', redis_global.used_memory_human) }} - {{ _self.row('Pic', redis_global.used_memory_peak_human) }} - -
-

Performance

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

{{ redis_global.error }}

+ Deconnecte {% endif %}
+ {% if redis_global.connected %} + {{ _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) }} +
+ {{ _self.r('Memoire', redis_global.used_memory_human ~ ' / pic ' ~ redis_global.used_memory_peak_human) }} +
+ {{ _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 %} +

{{ redis_global.error }}

+ {% endif %}
- {# Per-DB cards #} + {# Per-DB #} {% for db in redis_dbs %} -
-
-
-

{{ db.label }}

- {% if db.connected %} - {{ db.db }} - {% else %} - Erreur - {% endif %} -
- +
+
+

{{ db.label }}

{% if db.connected %} -
- {{ _self.row('Cles', db.keys|number_format(0, '.', ' ')) }} - {{ _self.row('TTL moyen', db.avg_ttl) }} -
+ {{ db.db }} {% else %} -

{{ db.error }}

+ Erreur {% endif %}
+ {% if db.connected %} + {{ _self.r('Cles', db.keys|number_format(0, '.', ' ')) }} + {{ _self.r('TTL moyen', db.avg_ttl) }} + {% else %} +

{{ db.error }}

+ {% endif %}
{% endfor %}
+ + {# ── COL 4: PostgreSQL & PgBouncer ─────────────────── #} +
+

PostgreSQL

+ + {# PostgreSQL #} +
+
+

Instance

+ {% if postgres.connected %} + v{{ postgres.version }} + {% else %} + Deconnecte + {% endif %} +
+ {% if postgres.connected %} + {{ _self.r('Uptime', postgres.uptime) }} + {{ _self.r('Taille BDD', postgres.db_size) }} + {{ _self.r('Tables', postgres.table_count) }} +
+ {{ _self.r('Connexions actives', postgres.active_connections) }} + {{ _self.r('Connexions total', postgres.total_connections ~ ' / ' ~ postgres.max_connections) }} +
+ {{ _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 %} +

{{ postgres.error }}

+ {% endif %} +
+ + {# PgBouncer #} +
+
+

PgBouncer

+ {% if pgbouncer.connected %} + Connecte + {% else %} + Deconnecte + {% endif %} +
+ {% if pgbouncer.connected %} + {% if pgbouncer.pools|length > 0 %} +

Pools

+ {% for pool in pgbouncer.pools %} +
+
+ {{ pool.database }} + {{ pool.pool_mode }} +
+
+ {{ _self.r('Cl. actifs', pool.cl_active) }} +
Cl. attente{{ pool.cl_waiting }}
+ {{ _self.r('Sv. actifs', pool.sv_active) }} + {{ _self.r('Sv. idle', pool.sv_idle) }} +
+
+ {% endfor %} + {% else %} +

Aucun pool actif.

+ {% endif %} + + {% if pgbouncer.stats|length > 0 %} +

Stats

+ {% for stat in pgbouncer.stats %} +
+

{{ stat.database }}

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

{{ pgbouncer.error }}

+ {% endif %} +
+
+
{% endblock %} -{% macro row(label, value) %} -
+{# ── Macros ──────────────────────────────────────────── #} + +{% macro r(label, value) %} +
{{ label }} {{ value }}
{% endmacro %} -{% macro row_color(label, value, color) %} -
+{% macro c(label, value, color) %} +
{{ label }} {{ value }}
{% endmacro %} + +{% macro svc(name, status, info) %} +
+ {{ name }} +
+ {{ info }} + {% if status == 'ok' %} + + {% else %} + + {% endif %} +
+
+{% endmacro %} + +{% macro svc_ssl(ssl) %} +
+ SSL {{ ssl.domain }} +
+ {{ ssl.issuer }} — {{ ssl.valid_until }} ({{ ssl.days_left }}j) + {% if ssl.status == 'ok' %} + + {% elseif ssl.status == 'warning' %} + + {% else %} + + {% endif %} +
+
+{% 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 %}