Reviewed-on: #1 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
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 applicationwith columns: id, name, slug (unique), registry_image, description, gitea_url, created_at -
CREATE TABLE environmentwith columns: id, name, container_name, deploy_script_path, maintenance_file_path, app_url, application_id (FK) -
CREATE TABLE log_filewith 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:
-
/applicationsshows 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:
- Redirected to
/login - Login as
admin/admin - Redirected to
/applications - See 3 application cards (SIRH, Lesstime, Inventory)
- Click SIRH → detail page with environments
- Toggle maintenance on SIRH prod → badge changes to orange
- Toggle maintenance off → badge changes to green
- Create a new application → card appears
- Add an environment to it → displays in detail
- Delete the environment → removed
- 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"