Files
Central/docs/superpowers/plans/2026-04-06-phase1-applications-environments.md
tristan 8f585b4be8
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
feat/ajout-de-fonctionnalites (#1)
Reviewed-on: #1
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-06 14:23:20 +00:00

61 KiB

Phase 1 — Applications & Environments Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace static YAML-based application management with dynamic database-backed CRUD for applications, environments, and log files, with a full UI.

Architecture: Three new Doctrine entities (Application, Environment, LogFile) exposed via API Platform with standard Doctrine providers. Frontend gets two new pages (list + detail) replacing the current dashboard. Old config-based system is removed entirely.

Tech Stack: PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, Pinia, Tailwind CSS, @malio/layer-ui


File Structure

Backend — Create

File Responsibility
src/Entity/Application.php Doctrine entity with API Platform resource config
src/Entity/Environment.php Doctrine entity with API Platform resource config
src/Entity/LogFile.php Doctrine entity, embedded in Environment
src/Repository/ApplicationRepository.php Doctrine repository for Application
src/Repository/EnvironmentRepository.php Doctrine repository for Environment
src/Repository/LogFileRepository.php Doctrine repository for LogFile
src/State/MaintenanceToggleProcessor.php Rewritten processor using new entities
migrations/VersionXXX.php Auto-generated migration for 3 new tables

Backend — Modify

File Change
src/DataFixtures/AppFixtures.php Add Application/Environment/LogFile seed data
config/services.yaml Remove applications.yaml import

Backend — Delete

File Reason
config/applications.yaml Replaced by database
src/ApiResource/ManagedApplication.php Replaced by Application entity
src/State/ManagedApplicationProvider.php Replaced by Doctrine default provider

Frontend — Create

File Responsibility
frontend/services/dto/application.ts TypeScript types for Application, Environment, LogFile
frontend/services/applications.ts CRUD service for applications
frontend/services/environments.ts CRUD service for environments + maintenance toggle
frontend/pages/applications/index.vue Applications list page
frontend/pages/applications/[slug].vue Application detail page

Frontend — Modify

File Change
frontend/layouts/default.vue Update sidebar link to /applications
frontend/i18n/locales/fr.json New translation keys

Frontend — Delete

File Reason
frontend/pages/index.vue Replaced by /applications
frontend/services/managed-applications.ts Replaced by new services
frontend/services/dto/managed-application.ts Replaced by new DTOs

Task 1: Application Entity

Files:

  • Create: src/Entity/Application.php

  • Create: src/Repository/ApplicationRepository.php

  • Step 1: Create ApplicationRepository

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Application;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Application>
 */
class ApplicationRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Application::class);
    }
}
  • Step 2: Create Application entity
<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ApplicationRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['app:read']],
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Get(
            uriVariables: ['slug'],
            normalizationContext: ['groups' => ['app:read', 'app:detail']],
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Post(
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Patch(
            uriVariables: ['slug'],
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Delete(
            uriVariables: ['slug'],
            security: "is_granted('ROLE_ADMIN')",
        ),
    ],
    normalizationContext: ['groups' => ['app:read']],
    denormalizationContext: ['groups' => ['app:write']],
)]
#[ORM\Entity(repositoryClass: ApplicationRepository::class)]
class Application
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['app:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['app:read', 'app:write'])]
    private ?string $name = null;

    #[ORM\Column(length: 255, unique: true)]
    #[ApiProperty(identifier: true)]
    #[Groups(['app:read', 'app:write'])]
    private ?string $slug = null;

    #[ORM\Column(length: 255)]
    #[Groups(['app:read', 'app:write'])]
    private ?string $registryImage = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    #[Groups(['app:read', 'app:write'])]
    private ?string $description = null;

    #[ORM\Column(length: 255, nullable: true)]
    #[Groups(['app:read', 'app:write'])]
    private ?string $giteaUrl = null;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    #[Groups(['app:read'])]
    private ?DateTimeImmutable $createdAt = null;

    /** @var Collection<int, Environment> */
    #[ORM\OneToMany(targetEntity: Environment::class, mappedBy: 'application', cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['app:detail'])]
    private Collection $environments;

    public function __construct()
    {
        $this->createdAt = new DateTimeImmutable();
        $this->environments = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    public function getSlug(): ?string
    {
        return $this->slug;
    }

    public function setSlug(string $slug): static
    {
        $this->slug = $slug;

        return $this;
    }

    public function getRegistryImage(): ?string
    {
        return $this->registryImage;
    }

    public function setRegistryImage(string $registryImage): static
    {
        $this->registryImage = $registryImage;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): static
    {
        $this->description = $description;

        return $this;
    }

    public function getGiteaUrl(): ?string
    {
        return $this->giteaUrl;
    }

    public function setGiteaUrl(?string $giteaUrl): static
    {
        $this->giteaUrl = $giteaUrl;

        return $this;
    }

    public function getCreatedAt(): ?DateTimeImmutable
    {
        return $this->createdAt;
    }

    /** @return Collection<int, Environment> */
    public function getEnvironments(): Collection
    {
        return $this->environments;
    }

    public function addEnvironment(Environment $environment): static
    {
        if (!$this->environments->contains($environment)) {
            $this->environments->add($environment);
            $environment->setApplication($this);
        }

        return $this;
    }

    public function removeEnvironment(Environment $environment): static
    {
        if ($this->environments->removeElement($environment)) {
            if ($environment->getApplication() === $this) {
                $environment->setApplication(null);
            }
        }

        return $this;
    }
}
  • Step 3: Commit
git add src/Entity/Application.php src/Repository/ApplicationRepository.php
git commit -m "feat : add Application entity with API Platform resource"

Task 2: Environment Entity

Files:

  • Create: src/Entity/Environment.php

  • Create: src/Repository/EnvironmentRepository.php

  • Step 1: Create EnvironmentRepository

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Environment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Environment>
 */
class EnvironmentRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Environment::class);
    }
}
  • Step 2: Create Environment entity
<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\EnvironmentRepository;
use App\State\MaintenanceToggleProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Post(
            uriTemplate: '/applications/{slug}/environments',
            uriVariables: [
                'slug' => new Link(fromClass: Application::class, fromProperty: 'environments'),
            ],
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Patch(
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Delete(
            security: "is_granted('ROLE_ADMIN')",
        ),
        new Post(
            uriTemplate: '/environments/{id}/maintenance',
            security: "is_granted('ROLE_ADMIN')",
            denormalizationContext: ['groups' => ['maintenance:write']],
            processor: MaintenanceToggleProcessor::class,
        ),
    ],
    normalizationContext: ['groups' => ['env:read']],
    denormalizationContext: ['groups' => ['env:write']],
)]
#[ORM\Entity(repositoryClass: EnvironmentRepository::class)]
class Environment
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['env:read', 'app:detail'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private ?string $name = null;

    #[ORM\Column(length: 255)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private ?string $containerName = null;

    #[ORM\Column(length: 255)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private ?string $deployScriptPath = null;

    #[ORM\Column(length: 255)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private ?string $maintenanceFilePath = null;

    #[ORM\Column(length: 255, nullable: true)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private ?string $appUrl = null;

    #[ORM\ManyToOne(targetEntity: Application::class, inversedBy: 'environments')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Application $application = null;

    /** @var Collection<int, LogFile> */
    #[ORM\OneToMany(targetEntity: LogFile::class, mappedBy: 'environment', cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private Collection $logFiles;

    #[Groups(['env:read', 'app:detail'])]
    private ?bool $maintenance = null;

    public function __construct()
    {
        $this->logFiles = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    public function getContainerName(): ?string
    {
        return $this->containerName;
    }

    public function setContainerName(string $containerName): static
    {
        $this->containerName = $containerName;

        return $this;
    }

    public function getDeployScriptPath(): ?string
    {
        return $this->deployScriptPath;
    }

    public function setDeployScriptPath(string $deployScriptPath): static
    {
        $this->deployScriptPath = $deployScriptPath;

        return $this;
    }

    public function getMaintenanceFilePath(): ?string
    {
        return $this->maintenanceFilePath;
    }

    public function setMaintenanceFilePath(string $maintenanceFilePath): static
    {
        $this->maintenanceFilePath = $maintenanceFilePath;

        return $this;
    }

    public function getAppUrl(): ?string
    {
        return $this->appUrl;
    }

    public function setAppUrl(?string $appUrl): static
    {
        $this->appUrl = $appUrl;

        return $this;
    }

    public function getApplication(): ?Application
    {
        return $this->application;
    }

    public function setApplication(?Application $application): static
    {
        $this->application = $application;

        return $this;
    }

    /** @return Collection<int, LogFile> */
    public function getLogFiles(): Collection
    {
        return $this->logFiles;
    }

    public function addLogFile(LogFile $logFile): static
    {
        if (!$this->logFiles->contains($logFile)) {
            $this->logFiles->add($logFile);
            $logFile->setEnvironment($this);
        }

        return $this;
    }

    public function removeLogFile(LogFile $logFile): static
    {
        if ($this->logFiles->removeElement($logFile)) {
            if ($logFile->getEnvironment() === $this) {
                $logFile->setEnvironment(null);
            }
        }

        return $this;
    }

    public function getMaintenance(): bool
    {
        return file_exists((string) $this->maintenanceFilePath);
    }
}
  • Step 3: Commit
git add src/Entity/Environment.php src/Repository/EnvironmentRepository.php
git commit -m "feat : add Environment entity with API Platform resource"

Task 3: LogFile Entity

Files:

  • Create: src/Entity/LogFile.php

  • Create: src/Repository/LogFileRepository.php

  • Step 1: Create LogFileRepository

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\LogFile;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<LogFile>
 */
class LogFileRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, LogFile::class);
    }
}
  • Step 2: Create LogFile entity
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\LogFileRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ORM\Entity(repositoryClass: LogFileRepository::class)]
class LogFile
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['env:read', 'app:detail'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private ?string $label = null;

    #[ORM\Column(length: 255)]
    #[Groups(['env:read', 'env:write', 'app:detail'])]
    private ?string $path = null;

    #[ORM\ManyToOne(targetEntity: Environment::class, inversedBy: 'logFiles')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Environment $environment = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getLabel(): ?string
    {
        return $this->label;
    }

    public function setLabel(string $label): static
    {
        $this->label = $label;

        return $this;
    }

    public function getPath(): ?string
    {
        return $this->path;
    }

    public function setPath(string $path): static
    {
        $this->path = $path;

        return $this;
    }

    public function getEnvironment(): ?Environment
    {
        return $this->environment;
    }

    public function setEnvironment(?Environment $environment): static
    {
        $this->environment = $environment;

        return $this;
    }
}
  • Step 3: Commit
git add src/Entity/LogFile.php src/Repository/LogFileRepository.php
git commit -m "feat : add LogFile entity"

Task 4: Generate and Run Migration

Files:

  • Create: migrations/VersionXXX.php (auto-generated)

  • Step 1: Generate migration

Run inside PHP container:

make shell
php bin/console doctrine:migrations:diff
exit

Expected: a new migration file is created in migrations/ with CREATE TABLE statements for application, environment, and log_file.

  • Step 2: Review the generated migration

Open the generated file and verify it contains:

  • CREATE TABLE application with columns: id, name, slug (unique), registry_image, description, gitea_url, created_at

  • CREATE TABLE environment with columns: id, name, container_name, deploy_script_path, maintenance_file_path, app_url, application_id (FK)

  • CREATE TABLE log_file with columns: id, label, path, environment_id (FK)

  • Unique index on application.slug

  • Foreign keys with ON DELETE CASCADE

  • Step 3: Run migration

make migration-migrate

Expected: migration executes successfully.

  • Step 4: Commit
git add migrations/
git commit -m "feat : add migration for application, environment, log_file tables"

Task 5: Rewrite MaintenanceToggleProcessor

Files:

  • Modify: src/State/MaintenanceToggleProcessor.php

  • Step 1: Rewrite the processor to use the Environment entity

Replace the entire file content:

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Environment;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

final readonly class MaintenanceToggleProcessor implements ProcessorInterface
{
    /**
     * @param Environment $data
     */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Environment
    {
        $maintenancePath = $data->getMaintenanceFilePath();

        if (null === $maintenancePath) {
            throw new BadRequestHttpException('Maintenance file path is not configured for this environment.');
        }

        $requestData = $context['request']?->toArray() ?? [];
        $enableMaintenance = $requestData['maintenance'] ?? false;

        if ($enableMaintenance) {
            $directory = dirname($maintenancePath);

            if (!is_dir($directory) && !mkdir($directory, 0755, true)) {
                throw new \RuntimeException(sprintf('Cannot create directory "%s".', $directory));
            }

            if (!touch($maintenancePath)) {
                throw new \RuntimeException(sprintf('Cannot create maintenance file at "%s".', $maintenancePath));
            }
        } elseif (file_exists($maintenancePath)) {
            if (!unlink($maintenancePath)) {
                throw new \RuntimeException(sprintf('Cannot remove maintenance file at "%s".', $maintenancePath));
            }
        }

        return $data;
    }
}
  • Step 2: Commit
git add src/State/MaintenanceToggleProcessor.php
git commit -m "refactor : rewrite MaintenanceToggleProcessor for Environment entity"

Task 6: Remove Old Application Config System

Files:

  • Delete: config/applications.yaml

  • Delete: src/ApiResource/ManagedApplication.php

  • Delete: src/State/ManagedApplicationProvider.php

  • Modify: config/services.yaml

  • Step 1: Remove the import from services.yaml

In config/services.yaml, remove the line:

    - { resource: applications.yaml }

So the imports section becomes:

imports:
    - { resource: version.yaml }
  • Step 2: Delete old files
rm config/applications.yaml
rm src/ApiResource/ManagedApplication.php
rm src/State/ManagedApplicationProvider.php
  • Step 3: Remove env variables from .env files

Remove SIRH_MAINTENANCE_PATH, LESSTIME_MAINTENANCE_PATH, INVENTORY_MAINTENANCE_PATH from .env and .env.local if they exist.

  • Step 4: Verify the app boots
make cache-clear

Expected: no errors.

  • Step 5: Commit
git add -A
git commit -m "refactor : remove old YAML-based application config system"

Task 7: Update Fixtures

Files:

  • Modify: src/DataFixtures/AppFixtures.php

  • Step 1: Add Application, Environment, and LogFile fixtures

Replace the entire file:

<?php

declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\Application;
use App\Entity\Environment;
use App\Entity\LogFile;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class AppFixtures extends Fixture
{
    public function __construct(
        private readonly UserPasswordHasherInterface $passwordHasher,
    ) {}

    public function load(ObjectManager $manager): void
    {
        $this->loadUsers($manager);
        $this->loadApplications($manager);

        $manager->flush();
    }

    private function loadUsers(ObjectManager $manager): void
    {
        $admin = new User();
        $admin->setUsername('admin');
        $admin->setRoles(['ROLE_ADMIN']);
        $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
        $manager->persist($admin);

        $userAlice = new User();
        $userAlice->setUsername('alice');
        $userAlice->setRoles(['ROLE_USER']);
        $userAlice->setPassword($this->passwordHasher->hashPassword($userAlice, 'alice'));
        $manager->persist($userAlice);

        $userBob = new User();
        $userBob->setUsername('bob');
        $userBob->setRoles(['ROLE_USER']);
        $userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob'));
        $manager->persist($userBob);
    }

    private function loadApplications(ObjectManager $manager): void
    {
        $sirh = new Application();
        $sirh->setName('SIRH');
        $sirh->setSlug('sirh');
        $sirh->setRegistryImage('gitea.malio.fr/malio-dev/sirh');
        $sirh->setDescription('Application de gestion des absences');
        $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->setAppUrl('https://sirh.malio-dev.fr');
        $sirh->addEnvironment($sirhProd);

        $sirhProdLog = new LogFile();
        $sirhProdLog->setLabel('prod');
        $sirhProdLog->setPath('/home/m-tristan/workspace/SIRH/var/log/prod.log');
        $sirhProd->addLogFile($sirhProdLog);

        $sirhCronLog = new LogFile();
        $sirhCronLog->setLabel('cron');
        $sirhCronLog->setPath('/home/m-tristan/workspace/SIRH/var/log/cron.log');
        $sirhProd->addLogFile($sirhCronLog);

        $manager->persist($sirh);

        $lesstime = new Application();
        $lesstime->setName('Lesstime');
        $lesstime->setSlug('lesstime');
        $lesstime->setRegistryImage('gitea.malio.fr/malio-dev/lesstime');
        $lesstime->setDescription('Application de gestion du temps');
        $lesstime->setGiteaUrl('https://gitea.malio.fr/malio-dev/lesstime');

        $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->setAppUrl('https://lesstime.malio-dev.fr');
        $lesstime->addEnvironment($lesstimeProd);

        $manager->persist($lesstime);

        $inventory = new Application();
        $inventory->setName('Inventory');
        $inventory->setSlug('inventory');
        $inventory->setRegistryImage('gitea.malio.fr/malio-dev/inventory');
        $inventory->setDescription('Application de gestion des inventaires');
        $inventory->setGiteaUrl('https://gitea.malio.fr/malio-dev/inventory');

        $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->setAppUrl('https://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->setAppUrl('https://inventory-test.malio-dev.fr');
        $inventory->addEnvironment($inventoryRecette);

        $manager->persist($inventory);
    }
}
  • Step 2: Load fixtures to verify
make db-reset

Expected: database reset succeeds, fixtures load without errors.

  • Step 3: Verify via API
# Login first
docker exec -t php-central-fpm curl -s -c /tmp/cookies -X POST http://nginx/login_check \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin"}'

# List applications
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/applications | python3 -m json.tool

Expected: JSON response with 3 applications (SIRH, Lesstime, Inventory).

# Get detail with environments
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/applications/sirh | python3 -m json.tool

Expected: SIRH application with its environments and log files nested.

  • Step 4: Commit
git add src/DataFixtures/AppFixtures.php
git commit -m "feat : add application, environment, logfile fixtures"

Task 8: Frontend TypeScript Types

Files:

  • Create: frontend/services/dto/application.ts

  • Delete: frontend/services/dto/managed-application.ts

  • Step 1: Create the new DTO file

type LogFile = {
    id?: number
    label: string
    path: string
}

type Environment = {
    id?: number
    '@id'?: string
    name: string
    containerName: string
    deployScriptPath: string
    maintenanceFilePath: string
    appUrl?: string
    logFiles: LogFile[]
    maintenance: boolean
}

type EnvironmentWrite = {
    name: string
    containerName: string
    deployScriptPath: string
    maintenanceFilePath: string
    appUrl?: string
    logFiles: LogFile[]
}

type Application = {
    id?: number
    '@id'?: string
    slug: string
    name: string
    registryImage: string
    description?: string
    giteaUrl?: string
    createdAt?: string
    environments?: Environment[]
}

type ApplicationWrite = {
    name: string
    slug: string
    registryImage: string
    description?: string
    giteaUrl?: string
}
  • Step 2: Delete old DTO
rm frontend/services/dto/managed-application.ts
  • Step 3: Commit
git add frontend/services/dto/application.ts
git rm frontend/services/dto/managed-application.ts
git commit -m "feat : add Application/Environment/LogFile TypeScript types"

Task 9: Frontend Application Service

Files:

  • Create: frontend/services/applications.ts

  • Delete: frontend/services/managed-applications.ts

  • Step 1: Create the applications service

export function getApplications(): Promise<Application[]> {
    const api = useApi()
    return api.get<Application[]>('/applications', undefined, {
        toast: false,
    }).then((response) => {
        if (Array.isArray(response)) {
            return response
        }
        return extractHydraMembers(response as HydraCollection<Application>)
    })
}

export function getApplication(slug: string): Promise<Application> {
    return useApi().get<Application>(`/applications/${slug}`, undefined, {
        toast: false,
    })
}

export function createApplication(data: ApplicationWrite): Promise<Application> {
    return useApi().post<Application>('/applications', data, {
        toastSuccessKey: 'success.applications.create',
        toastErrorKey: 'errors.applications.create',
    })
}

export function updateApplication(slug: string, data: Partial<ApplicationWrite>): Promise<Application> {
    return useApi().patch<Application>(`/applications/${slug}`, data, {
        toastSuccessKey: 'success.applications.update',
        toastErrorKey: 'errors.applications.update',
    })
}

export function deleteApplication(slug: string): Promise<void> {
    return useApi().delete<void>(`/applications/${slug}`, undefined, {
        toastSuccessKey: 'success.applications.delete',
        toastErrorKey: 'errors.applications.delete',
    })
}
  • Step 2: Delete old service
rm frontend/services/managed-applications.ts
  • Step 3: Commit
git add frontend/services/applications.ts
git rm frontend/services/managed-applications.ts
git commit -m "feat : add applications CRUD service"

Task 10: Frontend Environments Service

Files:

  • Create: frontend/services/environments.ts

  • Step 1: Create the environments service

export function createEnvironment(appSlug: string, data: EnvironmentWrite): Promise<Environment> {
    return useApi().post<Environment>(`/applications/${appSlug}/environments`, data, {
        toastSuccessKey: 'success.environments.create',
        toastErrorKey: 'errors.environments.create',
    })
}

export function updateEnvironment(id: number, data: Partial<EnvironmentWrite>): Promise<Environment> {
    return useApi().patch<Environment>(`/environments/${id}`, data, {
        toastSuccessKey: 'success.environments.update',
        toastErrorKey: 'errors.environments.update',
    })
}

export function deleteEnvironment(id: number): Promise<void> {
    return useApi().delete<void>(`/environments/${id}`, undefined, {
        toastSuccessKey: 'success.environments.delete',
        toastErrorKey: 'errors.environments.delete',
    })
}

export function toggleMaintenance(id: number, maintenance: boolean): Promise<Environment> {
    const successKey = maintenance
        ? 'success.environments.activateMaintenance'
        : 'success.environments.deactivateMaintenance'
    const errorKey = maintenance
        ? 'errors.environments.activateMaintenance'
        : 'errors.environments.deactivateMaintenance'

    return useApi().post<Environment>(`/environments/${id}/maintenance`, { maintenance }, {
        toastSuccessKey: successKey,
        toastErrorKey: errorKey,
    })
}
  • Step 2: Commit
git add frontend/services/environments.ts
git commit -m "feat : add environments service with maintenance toggle"

Task 11: Frontend i18n

Files:

  • Modify: frontend/i18n/locales/fr.json

  • Step 1: Update translations

Add/replace the following keys in fr.json. Keep existing keys for errors.http.*, errors.auth.*, success.auth.*, and dashboard.*. Replace the old applications.* and success.applications.* / errors.applications.* sections entirely:

{
    "errors": {
        "http": { "...existing..." },
        "applications": {
            "create": "Erreur lors de la creation de l'application",
            "update": "Erreur lors de la modification de l'application",
            "delete": "Erreur lors de la suppression de l'application",
            "load": "Erreur lors du chargement des applications"
        },
        "environments": {
            "create": "Erreur lors de la creation de l'environnement",
            "update": "Erreur lors de la modification de l'environnement",
            "delete": "Erreur lors de la suppression de l'environnement",
            "activateMaintenance": "Erreur lors de l'activation de la maintenance",
            "deactivateMaintenance": "Erreur lors de la desactivation de la maintenance"
        },
        "auth": { "...existing..." }
    },
    "success": {
        "applications": {
            "create": "Application creee avec succes",
            "update": "Application modifiee avec succes",
            "delete": "Application supprimee avec succes"
        },
        "environments": {
            "create": "Environnement cree avec succes",
            "update": "Environnement modifie avec succes",
            "delete": "Environnement supprime avec succes",
            "activateMaintenance": "Maintenance activee",
            "deactivateMaintenance": "Maintenance desactivee"
        },
        "auth": { "...existing..." }
    },
    "applications": {
        "title": "Applications",
        "description": "Gerer les applications du SI",
        "addButton": "Ajouter une application",
        "emptyTitle": "Aucune application",
        "emptyDescription": "Aucune application configuree pour le moment.",
        "card": {
            "environments": "environnement | environnements",
            "noEnvironments": "Aucun environnement configure"
        },
        "detail": {
            "title": "Detail de l'application",
            "registryImage": "Image registry",
            "giteaUrl": "Depot Gitea",
            "description": "Description",
            "editButton": "Modifier",
            "deleteButton": "Supprimer",
            "deleteConfirm": "Etes-vous sur de vouloir supprimer cette application et tous ses environnements ?"
        },
        "form": {
            "name": "Nom",
            "slug": "Slug",
            "registryImage": "Image registry",
            "description": "Description",
            "giteaUrl": "URL Gitea",
            "save": "Enregistrer",
            "cancel": "Annuler"
        }
    },
    "environments": {
        "title": "Environnements",
        "addButton": "Ajouter un environnement",
        "maintenance": {
            "active": "Maintenance active",
            "inactive": "En ligne",
            "activate": "Activer la maintenance",
            "deactivate": "Desactiver la maintenance",
            "pending": "En cours..."
        },
        "editButton": "Modifier",
        "deleteButton": "Supprimer",
        "deleteConfirm": "Etes-vous sur de vouloir supprimer cet environnement ?",
        "form": {
            "name": "Nom",
            "containerName": "Nom du container",
            "deployScriptPath": "Chemin du script de deploiement",
            "maintenanceFilePath": "Chemin du fichier de maintenance",
            "appUrl": "URL de l'application",
            "save": "Enregistrer",
            "cancel": "Annuler"
        },
        "logFiles": {
            "title": "Fichiers de log",
            "addButton": "Ajouter un fichier de log",
            "label": "Label",
            "path": "Chemin",
            "remove": "Retirer"
        }
    }
}

Note: merge these keys into the existing fr.json, preserving errors.http.*, errors.auth.*, success.auth.*, and dashboard.* as-is.

  • Step 2: Commit
git add frontend/i18n/locales/fr.json
git commit -m "feat : add i18n translations for applications and environments"

Task 12: Applications List Page

Files:

  • Create: frontend/pages/applications/index.vue

  • Delete: frontend/pages/index.vue

  • Modify: frontend/layouts/default.vue

  • Step 1: Create the applications list page

<script setup lang="ts">
const { t } = useI18n()
const router = useRouter()

const applications = ref<Application[]>([])
const loading = ref(true)
const showCreateForm = ref(false)
const createForm = ref<ApplicationWrite>({
    name: '',
    slug: '',
    registryImage: '',
    description: '',
    giteaUrl: '',
})
const isSubmitting = ref(false)

async function loadApplications() {
    loading.value = true
    try {
        applications.value = await getApplications()
    } finally {
        loading.value = false
    }
}

async function handleCreate() {
    isSubmitting.value = true
    try {
        const created = await createApplication(createForm.value)
        showCreateForm.value = false
        createForm.value = { name: '', slug: '', registryImage: '', description: '', giteaUrl: '' }
        router.push(`/applications/${created.slug}`)
    } finally {
        isSubmitting.value = false
    }
}

function generateSlug(name: string) {
    createForm.value.slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}

onMounted(loadApplications)
</script>

<template>
    <div class="p-6 max-w-6xl mx-auto">
        <div class="flex items-center justify-between mb-6">
            <div>
                <h1 class="text-2xl font-bold text-m-text">{{ t('applications.title') }}</h1>
                <p class="text-m-muted mt-1">{{ t('applications.description') }}</p>
            </div>
            <MalioButton @click="showCreateForm = !showCreateForm">
                {{ t('applications.addButton') }}
            </MalioButton>
        </div>

        <!-- Create form -->
        <div v-if="showCreateForm" class="bg-m-surface border border-m-border rounded-lg p-6 mb-6">
            <form @submit.prevent="handleCreate" class="space-y-4">
                <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                    <div>
                        <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.name') }}</label>
                        <MalioInputText
                            v-model="createForm.name"
                            @update:model-value="generateSlug"
                            required
                        />
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.slug') }}</label>
                        <MalioInputText v-model="createForm.slug" required />
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.registryImage') }}</label>
                        <MalioInputText v-model="createForm.registryImage" required />
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.giteaUrl') }}</label>
                        <MalioInputText v-model="createForm.giteaUrl" />
                    </div>
                </div>
                <div>
                    <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.description') }}</label>
                    <textarea
                        v-model="createForm.description"
                        class="w-full rounded border border-m-border bg-m-bg text-m-text p-2"
                        rows="2"
                    />
                </div>
                <div class="flex justify-end gap-2">
                    <MalioButton variant="secondary" @click="showCreateForm = false">
                        {{ t('applications.form.cancel') }}
                    </MalioButton>
                    <MalioButton type="submit" :loading="isSubmitting">
                        {{ t('applications.form.save') }}
                    </MalioButton>
                </div>
            </form>
        </div>

        <!-- Loading -->
        <div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
            <div v-for="i in 3" :key="i" class="bg-m-surface border border-m-border rounded-lg p-6 animate-pulse">
                <div class="h-5 bg-m-disabled rounded w-1/2 mb-3" />
                <div class="h-4 bg-m-disabled rounded w-3/4 mb-2" />
                <div class="h-4 bg-m-disabled rounded w-1/3" />
            </div>
        </div>

        <!-- Empty state -->
        <div v-else-if="applications.length === 0" class="text-center py-12">
            <h3 class="text-lg font-medium text-m-text">{{ t('applications.emptyTitle') }}</h3>
            <p class="text-m-muted mt-1">{{ t('applications.emptyDescription') }}</p>
        </div>

        <!-- Application cards -->
        <div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
            <NuxtLink
                v-for="app in applications"
                :key="app.slug"
                :to="`/applications/${app.slug}`"
                class="bg-m-surface border border-m-border rounded-lg p-6 hover:border-primary-500 transition-colors"
            >
                <div class="flex items-start justify-between">
                    <h3 class="text-lg font-semibold text-m-text">{{ app.name }}</h3>
                    <a
                        v-if="app.giteaUrl"
                        :href="app.giteaUrl"
                        target="_blank"
                        class="text-m-muted hover:text-primary-500"
                        @click.stop
                    >
                        <Icon name="mdi:open-in-new" size="18" />
                    </a>
                </div>
                <p v-if="app.description" class="text-m-muted text-sm mt-2 line-clamp-2">{{ app.description }}</p>
                <p class="text-m-muted text-xs mt-3">
                    <Icon name="mdi:server" size="14" class="mr-1" />
                    {{ app.environments?.length ?? 0 }} {{ t('applications.card.environments', app.environments?.length ?? 0) }}
                </p>
            </NuxtLink>
        </div>
    </div>
</template>
  • Step 2: Delete old index page
rm frontend/pages/index.vue
  • Step 3: Update sidebar link in default layout

In frontend/layouts/default.vue, find the SidebarLink for Dashboard and change its to prop from / to /applications and update the label/icon:

Change:

<SidebarLink to="/" icon="mdi:view-dashboard" :label="$t('dashboard.title')" :collapsed="uiStore.sidebarCollapsed" />

To:

<SidebarLink to="/applications" icon="mdi:apps" :label="$t('applications.title')" :collapsed="uiStore.sidebarCollapsed" />
  • Step 4: Verify in browser

Run make dev-nuxt, login as admin, verify:

  • /applications shows the 3 apps in a grid

  • Create form works

  • Clicking a card navigates to /applications/{slug}

  • Step 5: Commit

git rm frontend/pages/index.vue
git add frontend/pages/applications/index.vue frontend/layouts/default.vue
git commit -m "feat : add applications list page, replace old dashboard"

Task 13: Application Detail Page

Files:

  • Create: frontend/pages/applications/[slug].vue

  • Step 1: Create the detail page

<script setup lang="ts">
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string

const application = ref<Application | null>(null)
const loading = ref(true)
const editingApp = ref(false)
const editForm = ref<ApplicationWrite>({ name: '', slug: '', registryImage: '', description: '', giteaUrl: '' })
const isSubmitting = ref(false)

// Environment form
const showEnvForm = ref(false)
const editingEnvId = ref<number | null>(null)
const envForm = ref<EnvironmentWrite>({
    name: '',
    containerName: '',
    deployScriptPath: '',
    maintenanceFilePath: '',
    appUrl: '',
    logFiles: [],
})
const isSubmittingEnv = ref(false)
const pendingMaintenanceByEnvId = ref<Record<number, boolean>>({})

async function loadApplication() {
    loading.value = true
    try {
        application.value = await getApplication(slug)
    } finally {
        loading.value = false
    }
}

// Application edit
function startEditApp() {
    if (!application.value) return
    editForm.value = {
        name: application.value.name,
        slug: application.value.slug,
        registryImage: application.value.registryImage,
        description: application.value.description ?? '',
        giteaUrl: application.value.giteaUrl ?? '',
    }
    editingApp.value = true
}

async function saveApp() {
    isSubmitting.value = true
    try {
        application.value = await updateApplication(slug, editForm.value)
        editingApp.value = false
        if (editForm.value.slug !== slug) {
            router.replace(`/applications/${editForm.value.slug}`)
        }
    } finally {
        isSubmitting.value = false
    }
}

async function handleDeleteApp() {
    if (!confirm(t('applications.detail.deleteConfirm'))) return
    await deleteApplication(slug)
    router.push('/applications')
}

// Environment CRUD
function startCreateEnv() {
    editingEnvId.value = null
    envForm.value = { name: '', containerName: '', deployScriptPath: '', maintenanceFilePath: '', appUrl: '', logFiles: [] }
    showEnvForm.value = true
}

function startEditEnv(env: Environment) {
    editingEnvId.value = env.id!
    envForm.value = {
        name: env.name,
        containerName: env.containerName,
        deployScriptPath: env.deployScriptPath,
        maintenanceFilePath: env.maintenanceFilePath,
        appUrl: env.appUrl ?? '',
        logFiles: env.logFiles.map(lf => ({ label: lf.label, path: lf.path })),
    }
    showEnvForm.value = true
}

async function saveEnv() {
    isSubmittingEnv.value = true
    try {
        if (editingEnvId.value) {
            await updateEnvironment(editingEnvId.value, envForm.value)
        } else {
            await createEnvironment(slug, envForm.value)
        }
        showEnvForm.value = false
        await loadApplication()
    } finally {
        isSubmittingEnv.value = false
    }
}

async function handleDeleteEnv(envId: number) {
    if (!confirm(t('environments.deleteConfirm'))) return
    await deleteEnvironment(envId)
    await loadApplication()
}

async function handleToggleMaintenance(env: Environment) {
    const envId = env.id!
    pendingMaintenanceByEnvId.value[envId] = true
    try {
        await toggleMaintenance(envId, !env.maintenance)
        await loadApplication()
    } finally {
        delete pendingMaintenanceByEnvId.value[envId]
    }
}

function addLogFile() {
    envForm.value.logFiles.push({ label: '', path: '' })
}

function removeLogFile(index: number) {
    envForm.value.logFiles.splice(index, 1)
}

onMounted(loadApplication)
</script>

<template>
    <div class="p-6 max-w-5xl mx-auto">
        <!-- Back link -->
        <NuxtLink to="/applications" class="text-m-muted hover:text-primary-500 text-sm mb-4 inline-flex items-center gap-1">
            <Icon name="mdi:arrow-left" size="16" />
            {{ t('applications.title') }}
        </NuxtLink>

        <!-- Loading -->
        <div v-if="loading" class="animate-pulse mt-4">
            <div class="h-8 bg-m-disabled rounded w-1/3 mb-4" />
            <div class="h-4 bg-m-disabled rounded w-2/3 mb-2" />
            <div class="h-4 bg-m-disabled rounded w-1/2" />
        </div>

        <template v-else-if="application">
            <!-- Application info -->
            <div class="bg-m-surface border border-m-border rounded-lg p-6 mt-4">
                <template v-if="!editingApp">
                    <div class="flex items-start justify-between">
                        <div>
                            <h1 class="text-2xl font-bold text-m-text">{{ application.name }}</h1>
                            <p v-if="application.description" class="text-m-muted mt-2">{{ application.description }}</p>
                        </div>
                        <div class="flex gap-2">
                            <MalioButton variant="secondary" size="sm" @click="startEditApp">
                                {{ t('applications.detail.editButton') }}
                            </MalioButton>
                            <MalioButton variant="danger" size="sm" @click="handleDeleteApp">
                                {{ t('applications.detail.deleteButton') }}
                            </MalioButton>
                        </div>
                    </div>
                    <div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
                        <div>
                            <span class="text-m-muted">{{ t('applications.detail.registryImage') }} :</span>
                            <span class="text-m-text ml-1 font-mono">{{ application.registryImage }}</span>
                        </div>
                        <div v-if="application.giteaUrl">
                            <span class="text-m-muted">{{ t('applications.detail.giteaUrl') }} :</span>
                            <a :href="application.giteaUrl" target="_blank" class="text-primary-500 hover:underline ml-1">
                                {{ application.giteaUrl }}
                            </a>
                        </div>
                    </div>
                </template>

                <!-- Edit form -->
                <form v-else @submit.prevent="saveApp" class="space-y-4">
                    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                        <div>
                            <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.name') }}</label>
                            <MalioInputText v-model="editForm.name" required />
                        </div>
                        <div>
                            <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.slug') }}</label>
                            <MalioInputText v-model="editForm.slug" required />
                        </div>
                        <div>
                            <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.registryImage') }}</label>
                            <MalioInputText v-model="editForm.registryImage" required />
                        </div>
                        <div>
                            <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.giteaUrl') }}</label>
                            <MalioInputText v-model="editForm.giteaUrl" />
                        </div>
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-m-text mb-1">{{ t('applications.form.description') }}</label>
                        <textarea
                            v-model="editForm.description"
                            class="w-full rounded border border-m-border bg-m-bg text-m-text p-2"
                            rows="2"
                        />
                    </div>
                    <div class="flex justify-end gap-2">
                        <MalioButton variant="secondary" @click="editingApp = false">
                            {{ t('applications.form.cancel') }}
                        </MalioButton>
                        <MalioButton type="submit" :loading="isSubmitting">
                            {{ t('applications.form.save') }}
                        </MalioButton>
                    </div>
                </form>
            </div>

            <!-- Environments section -->
            <div class="mt-8">
                <div class="flex items-center justify-between mb-4">
                    <h2 class="text-xl font-bold text-m-text">{{ t('environments.title') }}</h2>
                    <MalioButton size="sm" @click="startCreateEnv">
                        {{ t('environments.addButton') }}
                    </MalioButton>
                </div>

                <!-- Environment form -->
                <div v-if="showEnvForm" class="bg-m-surface border border-m-border rounded-lg p-6 mb-4">
                    <form @submit.prevent="saveEnv" class="space-y-4">
                        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                            <div>
                                <label class="block text-sm font-medium text-m-text mb-1">{{ t('environments.form.name') }}</label>
                                <MalioInputText v-model="envForm.name" required />
                            </div>
                            <div>
                                <label class="block text-sm font-medium text-m-text mb-1">{{ t('environments.form.containerName') }}</label>
                                <MalioInputText v-model="envForm.containerName" required />
                            </div>
                            <div>
                                <label class="block text-sm font-medium text-m-text mb-1">{{ t('environments.form.deployScriptPath') }}</label>
                                <MalioInputText v-model="envForm.deployScriptPath" required />
                            </div>
                            <div>
                                <label class="block text-sm font-medium text-m-text mb-1">{{ t('environments.form.maintenanceFilePath') }}</label>
                                <MalioInputText v-model="envForm.maintenanceFilePath" required />
                            </div>
                            <div>
                                <label class="block text-sm font-medium text-m-text mb-1">{{ t('environments.form.appUrl') }}</label>
                                <MalioInputText v-model="envForm.appUrl" />
                            </div>
                        </div>

                        <!-- Log files -->
                        <div>
                            <div class="flex items-center justify-between mb-2">
                                <label class="text-sm font-medium text-m-text">{{ t('environments.logFiles.title') }}</label>
                                <button type="button" @click="addLogFile" class="text-primary-500 hover:underline text-sm">
                                    + {{ t('environments.logFiles.addButton') }}
                                </button>
                            </div>
                            <div v-for="(lf, index) in envForm.logFiles" :key="index" class="flex gap-2 mb-2 items-center">
                                <MalioInputText v-model="lf.label" :placeholder="t('environments.logFiles.label')" class="flex-1" required />
                                <MalioInputText v-model="lf.path" :placeholder="t('environments.logFiles.path')" class="flex-[2]" required />
                                <button type="button" @click="removeLogFile(index)" class="text-m-danger hover:text-red-700 p-1">
                                    <Icon name="mdi:close" size="18" />
                                </button>
                            </div>
                        </div>

                        <div class="flex justify-end gap-2">
                            <MalioButton variant="secondary" @click="showEnvForm = false">
                                {{ t('environments.form.cancel') }}
                            </MalioButton>
                            <MalioButton type="submit" :loading="isSubmittingEnv">
                                {{ t('environments.form.save') }}
                            </MalioButton>
                        </div>
                    </form>
                </div>

                <!-- Environments list -->
                <div v-if="!application.environments?.length && !showEnvForm" class="text-center py-8 text-m-muted">
                    {{ t('applications.card.noEnvironments') }}
                </div>

                <div v-for="env in application.environments" :key="env.id" class="bg-m-surface border border-m-border rounded-lg p-6 mb-4">
                    <div class="flex items-start justify-between">
                        <div>
                            <div class="flex items-center gap-3">
                                <h3 class="text-lg font-semibold text-m-text">{{ env.name }}</h3>
                                <span
                                    class="px-2 py-0.5 rounded-full text-xs font-medium"
                                    :class="env.maintenance
                                        ? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
                                        : 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'"
                                >
                                    {{ env.maintenance ? t('environments.maintenance.active') : t('environments.maintenance.inactive') }}
                                </span>
                            </div>
                            <p class="text-m-muted text-sm mt-1 font-mono">{{ env.containerName }}</p>
                            <a
                                v-if="env.appUrl"
                                :href="env.appUrl"
                                target="_blank"
                                class="text-primary-500 hover:underline text-sm mt-1 inline-flex items-center gap-1"
                            >
                                {{ env.appUrl }}
                                <Icon name="mdi:open-in-new" size="14" />
                            </a>
                        </div>
                        <div class="flex gap-2">
                            <MalioButton
                                size="sm"
                                :variant="env.maintenance ? 'secondary' : 'danger'"
                                :loading="!!pendingMaintenanceByEnvId[env.id!]"
                                @click="handleToggleMaintenance(env)"
                            >
                                {{ pendingMaintenanceByEnvId[env.id!]
                                    ? t('environments.maintenance.pending')
                                    : env.maintenance
                                        ? t('environments.maintenance.deactivate')
                                        : t('environments.maintenance.activate')
                                }}
                            </MalioButton>
                            <MalioButton variant="secondary" size="sm" @click="startEditEnv(env)">
                                {{ t('environments.editButton') }}
                            </MalioButton>
                            <MalioButton variant="danger" size="sm" @click="handleDeleteEnv(env.id!)">
                                {{ t('environments.deleteButton') }}
                            </MalioButton>
                        </div>
                    </div>

                    <!-- Log files -->
                    <div v-if="env.logFiles.length" class="mt-4 border-t border-m-border pt-3">
                        <p class="text-sm font-medium text-m-muted mb-2">{{ t('environments.logFiles.title') }}</p>
                        <div v-for="lf in env.logFiles" :key="lf.id" class="text-sm text-m-text flex gap-2">
                            <span class="font-medium">{{ lf.label }} :</span>
                            <span class="font-mono text-m-muted">{{ lf.path }}</span>
                        </div>
                    </div>
                </div>
            </div>
        </template>
    </div>
</template>
  • Step 2: Verify in browser

Run make dev-nuxt, navigate to /applications/sirh. Verify:

  • Application info displays correctly

  • Edit form works (modify name, save)

  • Environments list shows with maintenance badges

  • Maintenance toggle works

  • Environment create/edit/delete works

  • Log files display under each environment

  • Step 3: Commit

git add frontend/pages/applications/[slug].vue
git commit -m "feat : add application detail page with environment management"

Task 14: Update Auth Middleware Redirect

Files:

  • Modify: frontend/middleware/auth.global.ts

  • Step 1: Update redirect target

In frontend/middleware/auth.global.ts, change the redirect for authenticated users from / to /applications:

Change:

if (auth.isAuthenticated && to.path === '/login') {
    return navigateTo('/')
}

To:

if (auth.isAuthenticated && to.path === '/login') {
    return navigateTo('/applications')
}

Also add a redirect from / to /applications for authenticated users:

if (auth.isAuthenticated && to.path === '/') {
    return navigateTo('/applications')
}
  • Step 2: Commit
git add frontend/middleware/auth.global.ts
git commit -m "feat : redirect authenticated users to /applications"

Task 15: Final Verification

  • Step 1: Reset database and reload fixtures
make db-reset

Expected: succeeds without errors.

  • Step 2: Build frontend for production
make build-nuxtJS

Expected: Nuxt build succeeds.

  • Step 3: Run PHP tests
make test

Expected: all existing tests pass.

  • Step 4: Manual end-to-end test

Open http://localhost:8084 in browser:

  1. Redirected to /login
  2. Login as admin / admin
  3. Redirected to /applications
  4. See 3 application cards (SIRH, Lesstime, Inventory)
  5. Click SIRH → detail page with environments
  6. Toggle maintenance on SIRH prod → badge changes to orange
  7. Toggle maintenance off → badge changes to green
  8. Create a new application → card appears
  9. Add an environment to it → displays in detail
  10. Delete the environment → removed
  11. Delete the application → redirected to list
  • Step 5: Final commit

If any adjustments were needed during testing, commit them:

git add -A
git commit -m "fix : adjustments from end-to-end testing"