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:
@@ -10,6 +10,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
ports:
|
ports:
|
||||||
- "4578-4579:9000"
|
- "4578-4579:9000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -649,474 +649,9 @@ class AdminController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/infra', name: 'app_admin_infra', methods: ['GET'])]
|
#[Route('/infra', name: 'app_admin_infra', methods: ['GET'])]
|
||||||
public function infra(
|
public function infra(\App\Service\InfraService $infra): Response
|
||||||
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
|
|
||||||
{
|
{
|
||||||
$info = [];
|
return $this->render('admin/infra.html.twig', $infra->getAll());
|
||||||
|
|
||||||
// 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()];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
|
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
|
||||||
|
|||||||
495
src/Service/InfraService.php
Normal file
495
src/Service/InfraService.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,296 +3,277 @@
|
|||||||
{% block title %}Infrastructure{% endblock %}
|
{% block title %}Infrastructure{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% 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)]">
|
||||||
|
|
||||||
|
{# ── 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
{# 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 #}
|
{# CPU #}
|
||||||
<div class="flex-1 min-w-[280px]">
|
<div class="admin-card">
|
||||||
<div class="admin-card">
|
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">CPU</h3>
|
||||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">CPU</h2>
|
{{ _self.r('Modele', server.cpu_model) }}
|
||||||
<div class="flex flex-col gap-0">
|
{{ _self.r('Coeurs', server.cpu_cores) }}
|
||||||
{{ _self.row('Modele', server.cpu_model) }}
|
{{ _self.r('Load 1/5/15m', server.load_1m ~ ' / ' ~ server.load_5m ~ ' / ' ~ server.load_15m) }}
|
||||||
{{ _self.row('Coeurs', server.cpu_cores) }}
|
{{ _self.c('Charge', server.load_percent != '?' ? server.load_percent ~ '%' : '?', _self.pct_color(server.load_percent)) }}
|
||||||
{{ _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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# RAM #}
|
{# RAM #}
|
||||||
<div class="flex-1 min-w-[280px]">
|
<div class="admin-card">
|
||||||
<div class="admin-card">
|
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">RAM</h3>
|
||||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">RAM</h2>
|
{{ _self.r('Total', server.ram_total) }}
|
||||||
<div class="flex flex-col gap-0">
|
{{ _self.r('Utilisee', server.ram_used) }}
|
||||||
{{ _self.row('Total', server.ram_total) }}
|
{{ _self.r('Disponible', server.ram_free) }}
|
||||||
{{ _self.row('Utilisee', server.ram_used) }}
|
{{ _self.c('Utilisation', server.ram_percent != '?' ? server.ram_percent ~ '%' : '?', _self.pct_color(server.ram_percent)) }}
|
||||||
{{ _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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Disk #}
|
{# Disk #}
|
||||||
<div class="flex-1 min-w-[280px]">
|
<div class="admin-card">
|
||||||
<div class="admin-card">
|
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">Disque</h3>
|
||||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Disque</h2>
|
{{ _self.r('Total', server.disk_total) }}
|
||||||
<div class="flex flex-col gap-0">
|
{{ _self.r('Utilise', server.disk_used) }}
|
||||||
{{ _self.row('Total', server.disk_total) }}
|
{{ _self.r('Libre', server.disk_free) }}
|
||||||
{{ _self.row('Utilise', server.disk_used) }}
|
{{ _self.c('Utilisation', server.disk_percent != '?' ? server.disk_percent ~ '%' : '?', _self.pct_color(server.disk_percent)) }}
|
||||||
{{ _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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# System #}
|
{# Services #}
|
||||||
<div class="flex-1 min-w-[280px]">
|
<div class="admin-card">
|
||||||
<div class="admin-card">
|
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-3">Services</h3>
|
||||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Systeme</h2>
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-col gap-0">
|
{{ _self.svc('Caddy', server.caddy.status, server.caddy.info) }}
|
||||||
{{ _self.row('Hostname', server.hostname) }}
|
{{ _self.svc('Docker', server.docker.status, server.docker.info) }}
|
||||||
{{ _self.row('OS', server.os) }}
|
{{ _self.svc_ssl(server.ssl) }}
|
||||||
{{ _self.row('Uptime', server.uptime) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# PHP Containers #}
|
{# ── COL 2: Containers ─────────────────────────────── #}
|
||||||
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">PHP</h2>
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex flex-wrap gap-6 mb-8">
|
<h2 class="text-lg font-black uppercase tracking-tighter italic heading-page">Containers</h2>
|
||||||
{% for container in php_containers %}
|
|
||||||
<div class="flex-1 min-w-[300px]">
|
{% if containers|length > 0 %}
|
||||||
|
{% for c in containers %}
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<h2 class="text-sm font-black uppercase tracking-widest">{{ container.hostname }}</h2>
|
<h3 class="text-xs font-black uppercase tracking-widest">{{ c.name }}</h3>
|
||||||
<span class="admin-badge-green text-xs font-black uppercase">PHP {{ container.php_version }}</span>
|
{% if c.state == 'running' %}
|
||||||
</div>
|
<span class="admin-badge-green text-[9px] font-black uppercase">{{ c.state }}</span>
|
||||||
<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'))) }}
|
|
||||||
</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]">
|
|
||||||
<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 %}
|
{% else %}
|
||||||
<span class="admin-badge-red text-xs font-black uppercase">Deconnecte</span>
|
<span class="admin-badge-red text-[9px] font-black uppercase">{{ c.state }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-[10px] text-gray-400 mb-2">{{ c.image }} — {{ c.status }}</p>
|
||||||
{% if postgres.connected %}
|
<div class="grid grid-cols-3 gap-2 text-center">
|
||||||
<div class="flex flex-col gap-0">
|
<div>
|
||||||
{{ _self.row('Version', postgres.version) }}
|
<p class="text-[9px] font-black uppercase text-gray-400">CPU</p>
|
||||||
{{ _self.row('Uptime', postgres.uptime) }}
|
<p class="text-sm font-black {{ _self.pct_class(c.cpu_percent|replace({'%': ''})) }}">{{ c.cpu_percent }}</p>
|
||||||
{{ _self.row('Taille BDD', postgres.db_size) }}
|
</div>
|
||||||
{{ _self.row('Tables', postgres.table_count) }}
|
<div>
|
||||||
|
<p class="text-[9px] font-black uppercase text-gray-400">RAM</p>
|
||||||
<div class="border-t border-gray-200 my-2"></div>
|
<p class="text-sm font-black">{{ c.ram_used }}</p>
|
||||||
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Connexions</p>
|
</div>
|
||||||
{{ _self.row('Actives', postgres.active_connections) }}
|
<div>
|
||||||
{{ _self.row('Total', postgres.total_connections ~ ' / ' ~ postgres.max_connections) }}
|
<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 class="border-t border-gray-200 my-2"></div>
|
</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>
|
</div>
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-red-500 font-bold">{{ postgres.error }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
|
||||||
{# 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 %}
|
{% else %}
|
||||||
<div class="flex-1 min-w-[350px]">
|
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<p class="text-sm text-gray-400">Docker socket inaccessible.</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Redis #}
|
{# ── COL 3: Redis ──────────────────────────────────── #}
|
||||||
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">Redis</h2>
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex flex-wrap gap-6 mb-8">
|
<h2 class="text-lg font-black uppercase tracking-tighter italic heading-page">Redis</h2>
|
||||||
|
|
||||||
{# Global #}
|
{# Global #}
|
||||||
<div class="flex-1 min-w-[350px]">
|
<div class="admin-card">
|
||||||
<div class="admin-card">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400">Global</h3>
|
||||||
<h2 class="text-sm font-black uppercase tracking-widest">Global</h2>
|
|
||||||
{% if redis_global.connected %}
|
|
||||||
<span class="admin-badge-green text-xs font-black uppercase">Connecte</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="admin-badge-red text-xs font-black uppercase">Deconnecte</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if redis_global.connected %}
|
{% if redis_global.connected %}
|
||||||
<div class="flex flex-col gap-0">
|
<span class="admin-badge-green text-[9px] font-black uppercase">v{{ redis_global.version }}</span>
|
||||||
{{ _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>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm text-red-500 font-bold">{{ redis_global.error }}</p>
|
<span class="admin-badge-red text-[9px] font-black uppercase">Deconnecte</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% 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) }}
|
||||||
|
<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 %}
|
{% for db in redis_dbs %}
|
||||||
<div class="flex-1 min-w-[220px]">
|
<div class="admin-card">
|
||||||
<div class="admin-card">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<h3 class="text-[10px] font-black uppercase tracking-widest text-gray-400">{{ db.label }}</h3>
|
||||||
<h2 class="text-sm font-black uppercase tracking-widest">{{ db.label }}</h2>
|
|
||||||
{% if db.connected %}
|
|
||||||
<span class="admin-badge-green text-xs font-black uppercase">{{ db.db }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="admin-badge-red text-xs font-black uppercase">Erreur</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if db.connected %}
|
{% if db.connected %}
|
||||||
<div class="flex flex-col gap-0">
|
<span class="admin-badge-green text-[9px] font-black uppercase">{{ db.db }}</span>
|
||||||
{{ _self.row('Cles', db.keys|number_format(0, '.', ' ')) }}
|
|
||||||
{{ _self.row('TTL moyen', db.avg_ttl) }}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm text-red-500 font-bold">{{ db.error }}</p>
|
<span class="admin-badge-red text-[9px] font-black uppercase">Erreur</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if db.connected %}
|
||||||
|
{{ _self.r('Cles', db.keys|number_format(0, '.', ' ')) }}
|
||||||
|
{{ _self.r('TTL moyen', db.avg_ttl) }}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-red-500 font-bold">{{ db.error }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% macro row(label, value) %}
|
{# ── Macros ──────────────────────────────────────────── #}
|
||||||
<div class="flex justify-between py-1.5 text-sm">
|
|
||||||
|
{% 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="text-gray-500 font-bold">{{ label }}</span>
|
||||||
<span class="font-black">{{ value }}</span>
|
<span class="font-black">{{ value }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro row_color(label, value, color) %}
|
{% macro c(label, value, color) %}
|
||||||
<div class="flex justify-between py-1.5 text-sm">
|
<div class="flex justify-between py-0.5 text-[12px]">
|
||||||
<span class="text-gray-500 font-bold">{{ label }}</span>
|
<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>
|
<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>
|
</div>
|
||||||
{% endmacro %}
|
{% 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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user