feat : ajout de la lecture des logs symfony et docker (#3)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled

Reviewed-on: #3
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #3.
This commit is contained in:
2026-04-07 10:01:01 +00:00
committed by Autin
parent 7e342c9aeb
commit b769abdbe1
14 changed files with 525 additions and 19 deletions

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\DockerLogProvider;
use App\State\SymfonyLogProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/environments/{id}/logs/docker',
security: "is_granted('ROLE_ADMIN')",
provider: DockerLogProvider::class,
),
new Get(
uriTemplate: '/environments/{id}/logs/symfony/{logFileId}',
security: "is_granted('ROLE_ADMIN')",
provider: SymfonyLogProvider::class,
),
],
)]
final class LogOutput
{
public string $content = '';
public int $lines = 0;
public string $source = '';
}

View File

@@ -57,21 +57,21 @@ class AppFixtures extends Fixture
$sirh->setGiteaUrl('https://gitea.malio.fr/malio-dev/sirh');
$sirhProd = new Environment();
$sirhProd->setName('production');
$sirhProd->setContainerName('sirh-app');
$sirhProd->setDeployScriptPath('/home/m-tristan/workspace/SIRH/deploy/docker/deploy.sh');
$sirhProd->setMaintenanceFilePath('/home/m-tristan/workspace/SIRH/deploy/docker/maintenance.on');
$sirhProd->setName('Production');
$sirhProd->setContainerName('php-sirh-fpm');
$sirhProd->setDeployScriptPath('/SIRH/deploy/docker/deploy.sh');
$sirhProd->setMaintenanceFilePath('/SIRH/deploy/docker/maintenance.on');
$sirhProd->setAppUrl('http://sirh.malio-dev.fr');
$sirh->addEnvironment($sirhProd);
$sirhProdLog = new LogFile();
$sirhProdLog->setLabel('prod');
$sirhProdLog->setPath('/home/m-tristan/workspace/SIRH/var/log/prod.log');
$sirhProdLog->setLabel('dev');
$sirhProdLog->setPath('/SIRH/var/log/dev.log');
$sirhProd->addLogFile($sirhProdLog);
$sirhCronLog = new LogFile();
$sirhCronLog->setLabel('cron');
$sirhCronLog->setPath('/home/m-tristan/workspace/SIRH/var/log/cron.log');
$sirhCronLog->setPath('/SIRH/var/log/cron.log');
$sirhProd->addLogFile($sirhCronLog);
$manager->persist($sirh);
@@ -86,8 +86,8 @@ class AppFixtures extends Fixture
$lesstimeProd = new Environment();
$lesstimeProd->setName('production');
$lesstimeProd->setContainerName('lesstime-app');
$lesstimeProd->setDeployScriptPath('/home/m-tristan/workspace/lesstime/deploy/docker/deploy.sh');
$lesstimeProd->setMaintenanceFilePath('/home/m-tristan/workspace/lesstime/deploy/docker/maintenance.on');
$lesstimeProd->setDeployScriptPath('/lesstime/deploy/docker/deploy.sh');
$lesstimeProd->setMaintenanceFilePath('/lesstime/deploy/docker/maintenance.on');
$lesstimeProd->setAppUrl('http://lesstime.malio-dev.fr');
$lesstime->addEnvironment($lesstimeProd);
@@ -103,16 +103,16 @@ class AppFixtures extends Fixture
$inventoryProd = new Environment();
$inventoryProd->setName('production');
$inventoryProd->setContainerName('inventory-app');
$inventoryProd->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy.sh');
$inventoryProd->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance.on');
$inventoryProd->setDeployScriptPath('/inventory/deploy/docker/deploy.sh');
$inventoryProd->setMaintenanceFilePath('/inventory/deploy/docker/maintenance.on');
$inventoryProd->setAppUrl('http://inventory.malio-dev.fr');
$inventory->addEnvironment($inventoryProd);
$inventoryRecette = new Environment();
$inventoryRecette->setName('recette');
$inventoryRecette->setContainerName('inventory-test-app');
$inventoryRecette->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy-test.sh');
$inventoryRecette->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance-test.on');
$inventoryRecette->setDeployScriptPath('/inventory/deploy/docker/deploy-test.sh');
$inventoryRecette->setMaintenanceFilePath('/inventory/deploy/docker/maintenance-test.on');
$inventoryRecette->setAppUrl('http://inventory-test.malio-dev.fr');
$inventory->addEnvironment($inventoryRecette);

View File

@@ -8,11 +8,34 @@ use Symfony\Component\Process\Process;
final class DockerService
{
private ?bool $dockerAvailable = null;
private function isDockerAvailable(): bool
{
if (null === $this->dockerAvailable) {
$process = new Process(['which', 'docker']);
$process->setTimeout(5);
$process->run();
$this->dockerAvailable = $process->isSuccessful();
}
return $this->dockerAvailable;
}
/**
* @return array{status: string, image: string, version: string, startedAt: string}
*/
public function getContainerStatus(string $containerName): array
{
if (!$this->isDockerAvailable()) {
return [
'status' => 'unavailable',
'image' => '',
'version' => '',
'startedAt' => '',
];
}
$process = new Process([
'docker', 'inspect',
'--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}',
@@ -60,6 +83,15 @@ final class DockerService
*/
public function getContainerStats(string $containerName): array
{
if (!$this->isDockerAvailable()) {
return [
'cpuPercent' => 0.0,
'memoryUsage' => '',
'memoryLimit' => '',
'memoryPercent' => 0.0,
];
}
$process = new Process([
'docker', 'stats', '--no-stream',
'--format', '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}',

113
src/Service/LogService.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Process\Process;
final readonly class LogService
{
public function __construct(
private AppPathResolver $pathResolver,
) {}
public function getDockerLogs(string $containerName, int $lines = 100, ?string $since = null): string
{
$check = new Process(['which', 'docker']);
$check->setTimeout(5);
$check->run();
if (!$check->isSuccessful()) {
return 'Docker CLI is not available in this environment.';
}
$args = ['docker', 'logs', '--tail', (string) $lines];
if (null !== $since) {
$args[] = '--since';
$args[] = $since;
}
$args[] = $containerName;
$process = new Process($args);
$process->setTimeout(10);
$process->run();
return $process->getOutput() . $process->getErrorOutput();
}
public function getSymfonyLog(string $relativePath, int $lines = 100, ?string $level = null): string
{
$path = $this->pathResolver->resolve($relativePath);
if (!file_exists($path)) {
return sprintf('Log file not found: %s', $path);
}
// Read more lines than requested to compensate for filtering
$readLines = (null !== $level && '' !== $level) ? $lines * 5 : $lines;
$process = new Process(['tail', '-n', (string) $readLines, $path]);
$process->setTimeout(10);
$process->run();
$rawLines = explode("\n", trim($process->getOutput()));
$formatted = [];
foreach ($rawLines as $line) {
if ('' === $line) {
continue;
}
$parsed = $this->parseSymfonyLogLine($line);
if (null === $parsed) {
continue;
}
// Skip noisy channels
if ('doctrine' === $parsed['channel']) {
continue;
}
if (null !== $level && '' !== $level && !str_contains(strtoupper($parsed['level']), strtoupper($level))) {
continue;
}
$formatted[] = sprintf('[%s] %s.%s: %s', $parsed['date'], $parsed['channel'], $parsed['level'], $parsed['message']);
}
// Keep only the last N lines after filtering
$formatted = \array_slice($formatted, -$lines);
return implode("\n", $formatted);
}
/**
* @return array{date: string, level: string, channel: string, message: string}|null
*/
private function parseSymfonyLogLine(string $line): ?array
{
// Standard Symfony monolog format: [2026-04-03T15:33:19.304937+02:00] channel.LEVEL: message {context} []
if (preg_match('/^\[([^\]]+)\]\s+(\w+)\.(\w+):\s+(.+)$/', $line, $matches)) {
$date = substr($matches[1], 0, 19); // Trim to YYYY-MM-DDTHH:MM:SS
$message = $matches[4];
// Remove JSON context at the end: {"key":"value"} []
$message = preg_replace('/\s*\{.*\}\s*\[\]\s*$/', '', $message) ?? $message;
// Remove trailing []
$message = preg_replace('/\s*\[\]\s*$/', '', $message) ?? $message;
return [
'date' => str_replace('T', ' ', $date),
'level' => $matches[3],
'channel' => $matches[2],
'message' => $message,
];
}
return null;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\LogOutput;
use App\Repository\EnvironmentRepository;
use App\Service\LogService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class DockerLogProvider implements ProviderInterface
{
public function __construct(
private EnvironmentRepository $environmentRepository,
private LogService $logService,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): LogOutput
{
$id = $uriVariables['id'] ?? null;
$environment = $id ? $this->environmentRepository->find($id) : null;
if (null === $environment) {
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
}
$request = $this->requestStack->getCurrentRequest();
$lines = (int) ($request?->query->get('lines', '100') ?? 100);
$since = $request?->query->get('since');
$content = $this->logService->getDockerLogs(
$environment->getContainerName(),
$lines,
$since,
);
$dto = new LogOutput();
$dto->content = $content;
$dto->lines = $lines;
$dto->source = sprintf('docker:%s', $environment->getContainerName());
return $dto;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\LogOutput;
use App\Repository\EnvironmentRepository;
use App\Repository\LogFileRepository;
use App\Service\LogService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class SymfonyLogProvider implements ProviderInterface
{
public function __construct(
private EnvironmentRepository $environmentRepository,
private LogFileRepository $logFileRepository,
private LogService $logService,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): LogOutput
{
$envId = $uriVariables['id'] ?? null;
$logFileId = $uriVariables['logFileId'] ?? null;
$environment = $envId ? $this->environmentRepository->find($envId) : null;
if (null === $environment) {
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $envId));
}
$logFile = $logFileId ? $this->logFileRepository->find($logFileId) : null;
if (null === $logFile || $logFile->getEnvironment()?->getId() !== $environment->getId()) {
throw new NotFoundHttpException(sprintf('Log file "%s" not found.', $logFileId));
}
$request = $this->requestStack->getCurrentRequest();
$lines = (int) ($request?->query->get('lines', '100') ?? 100);
$level = $request?->query->get('level');
$content = $this->logService->getSymfonyLog(
$logFile->getPath(),
$lines,
$level,
);
$dto = new LogOutput();
$dto->content = $content;
$dto->lines = $lines;
$dto->source = sprintf('symfony:%s', $logFile->getLabel());
return $dto;
}
}