All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Reviewed-on: #1 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
1960 lines
61 KiB
Markdown
1960 lines
61 KiB
Markdown
# 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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
make migration-migrate
|
|
```
|
|
|
|
Expected: migration executes successfully.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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
|
|
<?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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```yaml
|
|
- { resource: applications.yaml }
|
|
```
|
|
|
|
So the imports section becomes:
|
|
|
|
```yaml
|
|
imports:
|
|
- { resource: version.yaml }
|
|
```
|
|
|
|
- [ ] **Step 2: Delete old files**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
make cache-clear
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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
|
|
<?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**
|
|
|
|
```bash
|
|
make db-reset
|
|
```
|
|
|
|
Expected: database reset succeeds, fixtures load without errors.
|
|
|
|
- [ ] **Step 3: Verify via API**
|
|
|
|
```bash
|
|
# 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).
|
|
|
|
```bash
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
rm frontend/services/dto/managed-application.ts
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
rm frontend/services/managed-applications.ts
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```json
|
|
{
|
|
"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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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:
|
|
```vue
|
|
<SidebarLink to="/" icon="mdi:view-dashboard" :label="$t('dashboard.title')" :collapsed="uiStore.sidebarCollapsed" />
|
|
```
|
|
|
|
To:
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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:
|
|
```typescript
|
|
if (auth.isAuthenticated && to.path === '/login') {
|
|
return navigateTo('/')
|
|
}
|
|
```
|
|
|
|
To:
|
|
```typescript
|
|
if (auth.isAuthenticated && to.path === '/login') {
|
|
return navigateTo('/applications')
|
|
}
|
|
```
|
|
|
|
Also add a redirect from `/` to `/applications` for authenticated users:
|
|
|
|
```typescript
|
|
if (auth.isAuthenticated && to.path === '/') {
|
|
return navigateTo('/applications')
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
make db-reset
|
|
```
|
|
|
|
Expected: succeeds without errors.
|
|
|
|
- [ ] **Step 2: Build frontend for production**
|
|
|
|
```bash
|
|
make build-nuxtJS
|
|
```
|
|
|
|
Expected: Nuxt build succeeds.
|
|
|
|
- [ ] **Step 3: Run PHP tests**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix : adjustments from end-to-end testing"
|
|
```
|