From b769abdbe1827f01fffd04846dec8b50d34c8067 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 7 Apr 2026 10:01:01 +0000 Subject: [PATCH] feat : ajout de la lecture des logs symfony et docker (#3) Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Central/pulls/3 Co-authored-by: tristan Co-committed-by: tristan --- docker-compose.yml | 1 + frontend/components/ui/LogModal.vue | 113 +++++++++++++++++++++++++ frontend/components/ui/SidebarLink.vue | 13 ++- frontend/i18n/locales/fr.json | 14 ++- frontend/pages/applications/[slug].vue | 74 +++++++++++++++- frontend/services/dto/logs.ts | 5 ++ frontend/services/logs.ts | 15 ++++ infra/dev/.env.docker | 1 + src/ApiResource/LogOutput.php | 31 +++++++ src/DataFixtures/AppFixtures.php | 26 +++--- src/Service/DockerService.php | 32 +++++++ src/Service/LogService.php | 113 +++++++++++++++++++++++++ src/State/DockerLogProvider.php | 49 +++++++++++ src/State/SymfonyLogProvider.php | 57 +++++++++++++ 14 files changed, 525 insertions(+), 19 deletions(-) create mode 100644 frontend/components/ui/LogModal.vue create mode 100644 frontend/services/dto/logs.ts create mode 100644 frontend/services/logs.ts create mode 100644 src/ApiResource/LogOutput.php create mode 100644 src/Service/LogService.php create mode 100644 src/State/DockerLogProvider.php create mode 100644 src/State/SymfonyLogProvider.php diff --git a/docker-compose.yml b/docker-compose.yml index 2a0c757..189521a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: - ./LOG:/var/www/html/LOG - uploads_data:/var/www/html/var/uploads - /var/run/docker.sock:/var/run/docker.sock + - ${HOST_APPS_PATH}:/mnt/apps extra_hosts: - "host.docker.internal:host-gateway" depends_on: diff --git a/frontend/components/ui/LogModal.vue b/frontend/components/ui/LogModal.vue new file mode 100644 index 0000000..16265aa --- /dev/null +++ b/frontend/components/ui/LogModal.vue @@ -0,0 +1,113 @@ + + + diff --git a/frontend/components/ui/SidebarLink.vue b/frontend/components/ui/SidebarLink.vue index f008b77..eeb937a 100644 --- a/frontend/components/ui/SidebarLink.vue +++ b/frontend/components/ui/SidebarLink.vue @@ -2,9 +2,7 @@ () +const route = useRoute() + +const isActive = computed(() => { + if (props.exact) { + return route.path === props.to + } + return route.path === props.to || route.path.startsWith(props.to + '/') +}) + const activeClass = computed(() => { if (props.collapsed) { return '!text-primary-500 bg-primary-500/10' diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 20956f5..82b9d14 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -48,6 +48,17 @@ "logout": "Déconnexion réussie." } }, + "logs": { + "docker": "Logs Docker", + "symfony": "Logs Symfony", + "lines": "Lignes", + "level": "Niveau", + "levelAll": "Tous", + "refresh": "Actualiser", + "noContent": "Aucun log disponible", + "copy": "Copier les logs", + "title": "Logs" + }, "dashboard": { "title": "Dashboard", "description": "Vue d'ensemble du SI", @@ -56,7 +67,8 @@ "running": "En ligne", "exited": "Arrete", "restarting": "Redemarrage", - "not_found": "Introuvable" + "not_found": "Introuvable", + "unavailable": "Docker indisponible" } }, "applications": { diff --git a/frontend/pages/applications/[slug].vue b/frontend/pages/applications/[slug].vue index 65dbb2e..1fa3499 100644 --- a/frontend/pages/applications/[slug].vue +++ b/frontend/pages/applications/[slug].vue @@ -6,6 +6,8 @@ import type { DeployResult } from '~/services/dto/deploy' import { getAvailableTags, deploy } from '~/services/deploy' import type { EnvironmentHealth } from '~/services/dto/dashboard' import { getEnvironmentHealth } from '~/services/dashboard' +import type { LogOutput } from '~/services/dto/logs' +import { getDockerLogs, getSymfonyLog } from '~/services/logs' const { t } = useI18n() const route = useRoute() @@ -29,6 +31,15 @@ const deployResult = ref(null) const healthByEnvId = ref>({}) const loadingHealth = ref(false) +// Log modals +const showLogModal = ref(false) +const logContent = ref('') +const logLoading = ref(false) +const logTitle = ref('') +const logEnvId = ref(null) +const logFileId = ref(null) +const logIsSymfony = ref(false) + // App edit modal const showAppModal = ref(false) const editForm = ref({ name: '', slug: '', registryImage: '', description: '', giteaUrl: '' }) @@ -226,6 +237,41 @@ function statusClass(status: string): string { } } +// Log functions +async function openDockerLogs(env: Environment) { + logEnvId.value = env.id! + logFileId.value = null + logIsSymfony.value = false + logTitle.value = `${t('logs.docker')} — ${env.name}` + logContent.value = '' + showLogModal.value = true +} + +async function openSymfonyLog(env: Environment, lf: { id?: number, label: string }) { + logEnvId.value = env.id! + logFileId.value = lf.id! + logIsSymfony.value = true + logTitle.value = `${t('logs.symfony')} — ${lf.label}` + logContent.value = '' + showLogModal.value = true +} + +async function refreshLogs(lines: number, level: string) { + if (!logEnvId.value) return + logLoading.value = true + try { + let result: LogOutput + if (logIsSymfony.value && logFileId.value) { + result = await getSymfonyLog(logEnvId.value, logFileId.value, lines, level || undefined) + } else { + result = await getDockerLogs(logEnvId.value, lines) + } + logContent.value = result.content + } finally { + logLoading.value = false + } +} + const envModalTitle = computed(() => editingEnvId.value ? t('environments.editButton') : t('environments.addButton') ) @@ -334,6 +380,13 @@ onMounted(loadApplication)
+

{{ t('environments.logFiles.title') }}

-
- {{ lf.label }} : +
+ {{ lf.label }} {{ lf.path }} +
@@ -613,5 +673,15 @@ onMounted(loadApplication) /> + + +
diff --git a/frontend/services/dto/logs.ts b/frontend/services/dto/logs.ts new file mode 100644 index 0000000..0489477 --- /dev/null +++ b/frontend/services/dto/logs.ts @@ -0,0 +1,5 @@ +type LogOutput = { + content: string + lines: number + source: string +} diff --git a/frontend/services/logs.ts b/frontend/services/logs.ts new file mode 100644 index 0000000..b6a02dd --- /dev/null +++ b/frontend/services/logs.ts @@ -0,0 +1,15 @@ +import type { LogOutput } from './dto/logs' + +export function getDockerLogs(envId: number, lines: number = 100): Promise { + return useApi().get(`/environments/${envId}/logs/docker`, { lines }, { + toast: false, + }) +} + +export function getSymfonyLog(envId: number, logFileId: number, lines: number = 100, level?: string): Promise { + const query: Record = { lines } + if (level) query.level = level + return useApi().get(`/environments/${envId}/logs/symfony/${logFileId}`, query, { + toast: false, + }) +} diff --git a/infra/dev/.env.docker b/infra/dev/.env.docker index ab92413..d8d24ac 100644 --- a/infra/dev/.env.docker +++ b/infra/dev/.env.docker @@ -7,3 +7,4 @@ POSTGRES_USER=root POSTGRES_PASSWORD=root POSTGRES_PORT=5436 XDEBUG_CLIENT_HOST=host.docker.internal +HOST_APPS_PATH=/home/user/workspace diff --git a/src/ApiResource/LogOutput.php b/src/ApiResource/LogOutput.php new file mode 100644 index 0000000..b26bfe0 --- /dev/null +++ b/src/ApiResource/LogOutput.php @@ -0,0 +1,31 @@ +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); diff --git a/src/Service/DockerService.php b/src/Service/DockerService.php index 896bc38..63b9fb8 100644 --- a/src/Service/DockerService.php +++ b/src/Service/DockerService.php @@ -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}}', diff --git a/src/Service/LogService.php b/src/Service/LogService.php new file mode 100644 index 0000000..8fe28f3 --- /dev/null +++ b/src/Service/LogService.php @@ -0,0 +1,113 @@ +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; + } +} diff --git a/src/State/DockerLogProvider.php b/src/State/DockerLogProvider.php new file mode 100644 index 0000000..10c52ce --- /dev/null +++ b/src/State/DockerLogProvider.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/src/State/SymfonyLogProvider.php b/src/State/SymfonyLogProvider.php new file mode 100644 index 0000000..27f903e --- /dev/null +++ b/src/State/SymfonyLogProvider.php @@ -0,0 +1,57 @@ +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; + } +}