Add PHP container stats to admin Infra page

Shows per-container: hostname, PHP version, SAPI, uptime, CPU cores,
CPU usage % (sampled from cgroup), load averages (1/5/15m), RAM used/
total/free with usage %. Color-coded: green <70%, yellow <90%, red >=90%.
Reads from cgroup v2 (fallback v1) and /proc for container-level stats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 10:38:33 +01:00
parent d2be7311d9
commit 7e53e3343b
2 changed files with 168 additions and 0 deletions

View File

@@ -663,14 +663,150 @@ class AdminController extends AbstractController
$pgInfo = $this->getPostgresInfo($em);
$pgbouncerInfo = $this->getPgBouncerInfo($databaseUrl);
$phpContainers = $this->getPhpContainerInfo();
return $this->render('admin/infra.html.twig', [
'redis_global' => $redisGlobal,
'redis_dbs' => [$redisMessenger, $redisSession, $redisCache],
'postgres' => $pgInfo,
'pgbouncer' => $pgbouncerInfo,
'php_containers' => $phpContainers,
]);
}
/**
* @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));

View File

@@ -5,6 +5,38 @@
{% block body %}
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page mb-8">Infrastructure</h1>
{# PHP Containers #}
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">PHP</h2>
<div class="flex flex-wrap gap-6 mb-8">
{% for container in php_containers %}
<div class="flex-1 min-w-[300px]">
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">{{ container.hostname }}</h2>
<span class="admin-badge-green text-xs font-black uppercase">PHP {{ container.php_version }}</span>
</div>
<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">