feat/ajout-de-fonctionnalites (#1)
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>
This commit was merged in pull request #1.
This commit is contained in:
2026-04-06 14:23:20 +00:00
committed by Autin
parent f80578c26a
commit 8f585b4be8
52 changed files with 6536 additions and 434 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,800 @@
# Phase 2a — Deploy & Available Versions 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:** Allow admins to list available Docker image tags from Gitea registry and deploy a chosen version to an environment, all from the Central UI.
**Architecture:** Two new PHP services (GiteaRegistryService for tag listing via Docker Registry V2 API, DeployService for script execution via Symfony Process). Two new API Platform endpoints exposed via custom providers/processors. Frontend adds a deploy modal on the environment detail. Docker socket mounted for deploy script execution.
**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Symfony HttpClient, Symfony Process, Nuxt 4, Vue 3
---
## File Structure
### Backend — Create
| File | Responsibility |
|------|---------------|
| `src/Service/GiteaRegistryService.php` | HTTP client for Gitea Docker Registry V2 API |
| `src/Service/DeployService.php` | Execute deploy.sh via Symfony Process |
| `src/ApiResource/TagList.php` | API Platform DTO for tag list response |
| `src/ApiResource/DeployResult.php` | API Platform DTO for deploy result response |
| `src/State/TagListProvider.php` | Provider that calls GiteaRegistryService |
| `src/State/DeployProcessor.php` | Processor that calls DeployService |
### Backend — Modify
| File | Change |
|------|--------|
| `.env` | Add GITEA_API_URL, GITEA_API_TOKEN |
| `docker-compose.yml` | Mount Docker socket in dev |
| `infra/prod/docker-compose.yml` | Mount Docker socket + deploy dirs in prod |
| `infra/prod/Dockerfile` | Install docker-cli in prod image |
### Frontend — Create
| File | Responsibility |
|------|---------------|
| `frontend/services/deploy.ts` | API calls for tags + deploy |
| `frontend/services/dto/deploy.ts` | TypeScript types for Tag and DeployResult |
### Frontend — Modify
| File | Change |
|------|--------|
| `frontend/pages/applications/[slug].vue` | Add deploy button + deploy modal |
| `frontend/i18n/locales/fr.json` | Deploy translation keys |
---
## Task 1: Environment variables and Docker config
**Files:**
- Modify: `.env`
- Modify: `docker-compose.yml`
- Modify: `infra/prod/docker-compose.yml`
- Modify: `infra/prod/Dockerfile`
- [ ] **Step 1: Add env vars to `.env`**
Add at the end of the file:
```
###> gitea ###
GITEA_API_URL=https://gitea.malio.fr
GITEA_API_TOKEN=change_me_in_env_local
###< gitea ###
```
- [ ] **Step 2: Mount Docker socket in dev docker-compose.yml**
In `docker-compose.yml`, add to the `php` service `volumes` list:
```yaml
- /var/run/docker.sock:/var/run/docker.sock
```
- [ ] **Step 3: Mount Docker socket + deploy dirs in prod docker-compose**
In `infra/prod/docker-compose.yml`, add to the `app` service `volumes` list:
```yaml
- /var/run/docker.sock:/var/run/docker.sock
- /var/www/sirh/deploy:/var/www/sirh/deploy:ro
- /var/www/lesstime/deploy:/var/www/lesstime/deploy:ro
- /var/www/inventory/deploy:/var/www/inventory/deploy:ro
```
- [ ] **Step 4: Install docker-cli in prod Dockerfile**
In `infra/prod/Dockerfile`, in the Stage 3 (production) `apt-get install` line, add `docker.io` to the list:
Replace:
```dockerfile
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
```
With:
```dockerfile
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor docker.io \
```
- [ ] **Step 5: Commit**
```bash
git add .env docker-compose.yml infra/prod/docker-compose.yml infra/prod/Dockerfile
git commit -m "feat : add Gitea env vars, mount Docker socket and deploy dirs"
```
---
## Task 2: GiteaRegistryService
**Files:**
- Create: `src/Service/GiteaRegistryService.php`
- [ ] **Step 1: Create the service**
```php
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class GiteaRegistryService
{
public function __construct(
private HttpClientInterface $httpClient,
#[Autowire('%env(GITEA_API_URL)%')]
private string $giteaApiUrl,
#[Autowire('%env(GITEA_API_TOKEN)%')]
private string $giteaApiToken,
) {}
/**
* List available tags for a container image.
*
* @param string $registryImage e.g. "gitea.malio.fr/malio-dev/sirh"
*
* @return list<string>
*/
public function listTags(string $registryImage): array
{
$parts = explode('/', $registryImage);
if (\count($parts) < 3) {
throw new \InvalidArgumentException(sprintf('Invalid registry image format: "%s". Expected "registry/owner/package".', $registryImage));
}
$owner = $parts[1];
$package = implode('/', \array_slice($parts, 2));
$url = sprintf('%s/v2/%s/%s/tags/list', $this->giteaApiUrl, $owner, $package);
$response = $this->httpClient->request('GET', $url, [
'headers' => [
'Authorization' => sprintf('token %s', $this->giteaApiToken),
],
'timeout' => 10,
]);
$data = $response->toArray();
$tags = $data['tags'] ?? [];
// Sort: versions (vX.Y.Z) first descending, then others
usort($tags, function (string $a, string $b): int {
$aIsVersion = str_starts_with($a, 'v');
$bIsVersion = str_starts_with($b, 'v');
if ($aIsVersion && $bIsVersion) {
return version_compare(ltrim($b, 'v'), ltrim($a, 'v'));
}
if ($aIsVersion) {
return -1;
}
if ($bIsVersion) {
return 1;
}
return strcmp($a, $b);
});
return $tags;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Service/GiteaRegistryService.php
git commit -m "feat : add GiteaRegistryService for listing container tags"
```
---
## Task 3: DeployService
**Files:**
- Create: `src/Service/DeployService.php`
- [ ] **Step 1: Create the service**
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Environment;
use Symfony\Component\Process\Process;
final class DeployService
{
/**
* @return array{success: bool, output: string, exitCode: int}
*/
public function deploy(Environment $environment, string $tag): array
{
$scriptPath = $environment->getDeployScriptPath();
if (null === $scriptPath || !file_exists($scriptPath)) {
return [
'success' => false,
'output' => sprintf('Deploy script not found: %s', $scriptPath ?? 'null'),
'exitCode' => 1,
];
}
$process = new Process(
['bash', $scriptPath, $tag],
dirname($scriptPath),
);
$process->setTimeout(300);
$process->run();
return [
'success' => $process->isSuccessful(),
'output' => $process->getOutput() . $process->getErrorOutput(),
'exitCode' => $process->getExitCode() ?? 1,
];
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Service/DeployService.php
git commit -m "feat : add DeployService for executing deploy scripts"
```
---
## Task 4: API Platform DTOs
**Files:**
- Create: `src/ApiResource/TagList.php`
- Create: `src/ApiResource/DeployResult.php`
- [ ] **Step 1: Create TagList DTO**
```php
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\TagListProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/applications/{slug}/tags',
security: "is_granted('ROLE_ADMIN')",
provider: TagListProvider::class,
),
],
)]
final class TagList
{
/** @var list<string> */
public array $tags = [];
}
```
- [ ] **Step 2: Create DeployResult DTO**
```php
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\DeployProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/environments/{id}/deploy',
security: "is_granted('ROLE_ADMIN')",
processor: DeployProcessor::class,
),
],
)]
final class DeployResult
{
public bool $success = false;
public string $output = '';
public string $tag = '';
}
```
- [ ] **Step 3: Commit**
```bash
git add src/ApiResource/TagList.php src/ApiResource/DeployResult.php
git commit -m "feat : add TagList and DeployResult API Platform DTOs"
```
---
## Task 5: TagListProvider
**Files:**
- Create: `src/State/TagListProvider.php`
- [ ] **Step 1: Create the provider**
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\TagList;
use App\Repository\ApplicationRepository;
use App\Service\GiteaRegistryService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class TagListProvider implements ProviderInterface
{
public function __construct(
private ApplicationRepository $applicationRepository,
private GiteaRegistryService $giteaRegistryService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TagList
{
$slug = $uriVariables['slug'] ?? '';
$application = $this->applicationRepository->findOneBy(['slug' => $slug]);
if (null === $application) {
throw new NotFoundHttpException(sprintf('Application "%s" not found.', $slug));
}
$dto = new TagList();
$dto->tags = $this->giteaRegistryService->listTags($application->getRegistryImage());
return $dto;
}
}
```
- [ ] **Step 2: Verify endpoint works**
```bash
# Login
docker exec -t php-central-fpm curl -s -c /tmp/cookies -X POST http://nginx/api/login_check \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}'
# List tags (will fail if GITEA_API_TOKEN is not set in .env.local, that's expected in dev)
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/applications/sirh/tags
```
- [ ] **Step 3: Commit**
```bash
git add src/State/TagListProvider.php
git commit -m "feat : add TagListProvider for listing registry tags"
```
---
## Task 6: DeployProcessor
**Files:**
- Create: `src/State/DeployProcessor.php`
- [ ] **Step 1: Create the processor**
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\DeployResult;
use App\Repository\EnvironmentRepository;
use App\Service\DeployService;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class DeployProcessor implements ProcessorInterface
{
public function __construct(
private EnvironmentRepository $environmentRepository,
private DeployService $deployService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): DeployResult
{
$id = $uriVariables['id'] ?? null;
$environment = $id ? $this->environmentRepository->find($id) : null;
if (null === $environment) {
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
}
$requestData = $context['request']?->toArray() ?? [];
$tag = $requestData['tag'] ?? null;
if (null === $tag || '' === $tag) {
throw new BadRequestHttpException('The "tag" field is required.');
}
$result = $this->deployService->deploy($environment, $tag);
$dto = new DeployResult();
$dto->success = $result['success'];
$dto->output = $result['output'];
$dto->tag = $tag;
return $dto;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/State/DeployProcessor.php
git commit -m "feat : add DeployProcessor for triggering deployments"
```
---
## Task 7: Frontend types and service
**Files:**
- Create: `frontend/services/dto/deploy.ts`
- Create: `frontend/services/deploy.ts`
- [ ] **Step 1: Create deploy types**
```typescript
type Tag = {
name: string
}
type TagListResponse = {
tags: string[]
}
type DeployResult = {
success: boolean
output: string
tag: string
}
```
- [ ] **Step 2: Create deploy service**
```typescript
import type { TagListResponse, DeployResult } from './dto/deploy'
export function getAvailableTags(slug: string): Promise<TagListResponse> {
return useApi().get<TagListResponse>(`/applications/${slug}/tags`, undefined, {
toast: false,
})
}
export function deploy(envId: number, tag: string): Promise<DeployResult> {
return useApi().post<DeployResult>(`/environments/${envId}/deploy`, { tag }, {
toast: false,
})
}
```
- [ ] **Step 3: Commit**
```bash
git add frontend/services/dto/deploy.ts frontend/services/deploy.ts
git commit -m "feat : add frontend deploy types and service"
```
---
## Task 8: i18n translations
**Files:**
- Modify: `frontend/i18n/locales/fr.json`
- [ ] **Step 1: Add deploy translations**
Add the following keys to the existing `environments` section in `fr.json`:
```json
{
"environments": {
"...existing keys...",
"deploy": {
"button": "Deployer",
"title": "Deployer une version",
"selectTag": "Version a deployer",
"selectPlaceholder": "Selectionner une version",
"loadingTags": "Chargement des versions...",
"noTags": "Aucune version disponible",
"confirm": "Deployer",
"deploying": "Deploiement en cours...",
"success": "Deploiement reussi",
"error": "Echec du deploiement",
"output": "Sortie du deploiement"
}
}
}
```
Also add error keys:
```json
{
"errors": {
"...existing keys...",
"deploy": {
"tags": "Erreur lors du chargement des versions",
"deploy": "Erreur lors du deploiement"
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat : add i18n translations for deploy feature"
```
---
## Task 9: Deploy modal and button on detail page
**Files:**
- Modify: `frontend/pages/applications/[slug].vue`
- [ ] **Step 1: Add imports and deploy state**
At the top of `<script setup>`, add the imports after existing ones:
```typescript
import type { DeployResult } from '~/services/dto/deploy'
import { getAvailableTags, deploy } from '~/services/deploy'
```
Add state variables after the existing ones (after `pendingMaintenanceByEnvId`):
```typescript
// Deploy modal
const showDeployModal = ref(false)
const deployEnvId = ref<number | null>(null)
const deployTags = ref<string[]>([])
const selectedTag = ref('')
const loadingTags = ref(false)
const isDeploying = ref(false)
const deployResult = ref<DeployResult | null>(null)
```
- [ ] **Step 2: Add deploy functions**
Add these functions after `removeLogFile`:
```typescript
async function openDeployModal(env: Environment) {
deployEnvId.value = env.id!
selectedTag.value = ''
deployResult.value = null
deployTags.value = []
showDeployModal.value = true
loadingTags.value = true
try {
const response = await getAvailableTags(slug)
deployTags.value = response.tags ?? []
if (deployTags.value.length > 0) {
selectedTag.value = deployTags.value[0]
}
} finally {
loadingTags.value = false
}
}
async function handleDeploy() {
if (!deployEnvId.value || !selectedTag.value) return
isDeploying.value = true
deployResult.value = null
try {
deployResult.value = await deploy(deployEnvId.value, selectedTag.value)
if (deployResult.value.success) {
await loadApplication()
}
} finally {
isDeploying.value = false
}
}
function closeDeployModal() {
showDeployModal.value = false
deployResult.value = null
}
```
- [ ] **Step 3: Add deploy button to each environment**
In the template, in the environment action buttons area (the `<div class="flex gap-2">` that contains the maintenance toggle button), add a deploy button before the maintenance button:
```vue
<MalioButton
:label="t('environments.deploy.button')"
icon-name="mdi:rocket-launch-outline"
icon-position="left"
@click="openDeployModal(env)"
/>
```
- [ ] **Step 4: Add deploy modal to template**
Add after the existing environment modal (before the closing `</div>` of the root template):
```vue
<!-- Deploy modal -->
<AppModal
v-model="showDeployModal"
:submit-label="isDeploying ? t('environments.deploy.deploying') : t('environments.deploy.confirm')"
:cancel-label="t('applications.form.cancel')"
:loading="isDeploying"
max-width="xl"
@submit="handleDeploy"
@update:model-value="!$event && closeDeployModal()"
>
<template #title>{{ t('environments.deploy.title') }}</template>
<!-- Tag selection -->
<div v-if="!deployResult">
<div v-if="loadingTags" class="py-8 text-center text-neutral-400">
<Icon name="mdi:loading" size="24" class="animate-spin" />
<p class="mt-2 text-sm">{{ t('environments.deploy.loadingTags') }}</p>
</div>
<div v-else-if="deployTags.length === 0" class="py-8 text-center text-neutral-400">
<Icon name="mdi:package-variant" size="32" />
<p class="mt-2 text-sm">{{ t('environments.deploy.noTags') }}</p>
</div>
<div v-else>
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ t('environments.deploy.selectTag') }}
</label>
<select
v-model="selectedTag"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option v-for="tag in deployTags" :key="tag" :value="tag">
{{ tag }}
</option>
</select>
</div>
</div>
<!-- Deploy result -->
<div v-else>
<div
class="mb-4 flex items-center gap-2 rounded-lg px-4 py-3"
:class="deployResult.success
? 'bg-green-50 text-green-700'
: 'bg-red-50 text-red-700'"
>
<Icon
:name="deployResult.success ? 'mdi:check-circle' : 'mdi:alert-circle'"
size="20"
/>
<span class="text-sm font-semibold">
{{ deployResult.success
? t('environments.deploy.success')
: t('environments.deploy.error')
}}
</span>
<span class="ml-auto text-xs font-mono">{{ deployResult.tag }}</span>
</div>
<div>
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">
{{ t('environments.deploy.output') }}
</p>
<pre class="max-h-80 overflow-auto rounded-lg bg-neutral-900 p-4 text-xs text-green-400 font-mono whitespace-pre-wrap">{{ deployResult.output }}</pre>
</div>
</div>
<!-- Override footer when showing result -->
<template v-if="deployResult" #footer>
<MalioButton
:label="t('applications.form.cancel')"
variant="tertiary"
@click="closeDeployModal"
/>
</template>
</AppModal>
```
- [ ] **Step 5: Verify in browser**
Run `make dev-nuxt`, login, go to `/applications/sirh`:
- Each environment should show a "Deployer" button with rocket icon
- Clicking it opens a modal
- The tag list will load (may fail if GITEA_API_TOKEN not configured — that's expected)
- The deploy will fail in dev (script not accessible) — that's expected
- [ ] **Step 6: Commit**
```bash
git add frontend/pages/applications/[slug].vue
git commit -m "feat : add deploy modal with tag selection and result display"
```
---
## Task 10: Build and verify
- [ ] **Step 1: Clear Symfony cache**
```bash
docker exec -t -u www-data php-central-fpm php bin/console cache:clear
```
- [ ] **Step 2: Build frontend**
```bash
make build-nuxtJS
```
- [ ] **Step 3: Verify API endpoints exist**
```bash
# Login
docker exec -t php-central-fpm curl -s -c /tmp/cookies -X POST http://nginx/api/login_check \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}'
# Tags endpoint (will return error about token, that's OK)
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/applications/sirh/tags
# Deploy endpoint (will return error about script, that's OK)
docker exec -t php-central-fpm curl -s -b /tmp/cookies -X POST http://nginx/api/environments/1/deploy \
-H "Content-Type: application/json" \
-d '{"tag":"latest"}'
```
Both endpoints should return structured JSON responses (even if errors), not 404s.
- [ ] **Step 4: Final commit if adjustments needed**
```bash
git add -A
git commit -m "fix : adjustments from end-to-end testing"
```

View File

@@ -0,0 +1,837 @@
# Phase 2b — Dashboard Sante 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:** Add a dashboard showing container health status (running/stopped) and version for all environments, plus detailed metrics (CPU, memory, uptime) on the application detail page.
**Architecture:** A DockerService executes Docker CLI commands via Process to get container status and stats. Two new API Platform endpoints expose dashboard overview and per-environment health. Frontend adds a new dashboard page and enriches the detail page.
**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Symfony Process, Docker CLI, Nuxt 4, Vue 3
---
## File Structure
### Backend — Create
| File | Responsibility |
|------|---------------|
| `src/Service/DockerService.php` | Execute Docker CLI commands via Process |
| `src/ApiResource/Dashboard.php` | API Platform DTO for dashboard response |
| `src/ApiResource/EnvironmentHealth.php` | API Platform DTO for env health response |
| `src/State/DashboardProvider.php` | Provider that aggregates all apps + container status |
| `src/State/EnvironmentHealthProvider.php` | Provider for single env detailed health |
### Frontend — Create
| File | Responsibility |
|------|---------------|
| `frontend/services/dashboard.ts` | API calls for dashboard and env health |
| `frontend/services/dto/dashboard.ts` | TypeScript types |
| `frontend/pages/dashboard.vue` | Dashboard page |
### Frontend — Modify
| File | Change |
|------|--------|
| `frontend/pages/applications/[slug].vue` | Add health metrics block per env |
| `frontend/layouts/default.vue` | Add Dashboard sidebar link |
| `frontend/middleware/auth.global.ts` | Redirect `/` to `/dashboard` instead of `/applications` |
| `frontend/i18n/locales/fr.json` | Dashboard translation keys |
---
## Task 1: DockerService
**Files:**
- Create: `src/Service/DockerService.php`
- [ ] **Step 1: Create the service**
```php
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Process\Process;
final class DockerService
{
/**
* @return array{status: string, image: string, version: string, startedAt: string}
*/
public function getContainerStatus(string $containerName): array
{
$process = new Process([
'docker', 'inspect',
'--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}',
$containerName,
]);
$process->setTimeout(10);
$process->run();
if (!$process->isSuccessful()) {
return [
'status' => 'not_found',
'image' => '',
'version' => '',
'startedAt' => '',
];
}
$parts = explode('||', trim($process->getOutput()));
if (\count($parts) < 3) {
return [
'status' => 'not_found',
'image' => '',
'version' => '',
'startedAt' => '',
];
}
$image = $parts[1];
$version = 'latest';
if (str_contains($image, ':')) {
$version = substr($image, strrpos($image, ':') + 1);
}
return [
'status' => $parts[0],
'image' => $image,
'version' => $version,
'startedAt' => $parts[2],
];
}
/**
* @return array{cpuPercent: float, memoryUsage: string, memoryLimit: string, memoryPercent: float}
*/
public function getContainerStats(string $containerName): array
{
$process = new Process([
'docker', 'stats', '--no-stream',
'--format', '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}',
$containerName,
]);
$process->setTimeout(10);
$process->run();
if (!$process->isSuccessful()) {
return [
'cpuPercent' => 0.0,
'memoryUsage' => '',
'memoryLimit' => '',
'memoryPercent' => 0.0,
];
}
$parts = explode('||', trim($process->getOutput()));
if (\count($parts) < 3) {
return [
'cpuPercent' => 0.0,
'memoryUsage' => '',
'memoryLimit' => '',
'memoryPercent' => 0.0,
];
}
$memParts = explode(' / ', $parts[1]);
return [
'cpuPercent' => (float) rtrim($parts[0], '%'),
'memoryUsage' => $memParts[0] ?? '',
'memoryLimit' => $memParts[1] ?? '',
'memoryPercent' => (float) rtrim($parts[2], '%'),
];
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Service/DockerService.php
git commit -m "feat : add DockerService for container status and stats"
```
---
## Task 2: API Platform DTOs
**Files:**
- Create: `src/ApiResource/Dashboard.php`
- Create: `src/ApiResource/EnvironmentHealth.php`
- [ ] **Step 1: Create Dashboard DTO**
```php
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\DashboardProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/dashboard',
security: "is_granted('ROLE_ADMIN')",
provider: DashboardProvider::class,
),
],
)]
final class Dashboard
{
/** @var list<array{name: string, slug: string, giteaUrl: ?string, environments: list<array{id: int, name: string, status: string, version: string}>}> */
public array $applications = [];
}
```
- [ ] **Step 2: Create EnvironmentHealth DTO**
```php
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\EnvironmentHealthProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/environments/{id}/health',
security: "is_granted('ROLE_ADMIN')",
provider: EnvironmentHealthProvider::class,
),
],
)]
final class EnvironmentHealth
{
public string $status = 'not_found';
public string $version = '';
public string $startedAt = '';
public float $cpuPercent = 0.0;
public string $memoryUsage = '';
public string $memoryLimit = '';
public float $memoryPercent = 0.0;
}
```
- [ ] **Step 3: Commit**
```bash
git add src/ApiResource/Dashboard.php src/ApiResource/EnvironmentHealth.php
git commit -m "feat : add Dashboard and EnvironmentHealth API Platform DTOs"
```
---
## Task 3: DashboardProvider
**Files:**
- Create: `src/State/DashboardProvider.php`
- [ ] **Step 1: Create the provider**
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\Dashboard;
use App\Repository\ApplicationRepository;
use App\Service\DockerService;
final readonly class DashboardProvider implements ProviderInterface
{
public function __construct(
private ApplicationRepository $applicationRepository,
private DockerService $dockerService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Dashboard
{
$applications = $this->applicationRepository->findAll();
$dto = new Dashboard();
foreach ($applications as $app) {
$envs = [];
foreach ($app->getEnvironments() as $env) {
$containerStatus = $this->dockerService->getContainerStatus($env->getContainerName());
$envs[] = [
'id' => $env->getId(),
'name' => $env->getName(),
'status' => $containerStatus['status'],
'version' => $containerStatus['version'],
];
}
$dto->applications[] = [
'name' => $app->getName(),
'slug' => $app->getSlug(),
'giteaUrl' => $app->getGiteaUrl(),
'environments' => $envs,
];
}
return $dto;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/State/DashboardProvider.php
git commit -m "feat : add DashboardProvider for container status overview"
```
---
## Task 4: EnvironmentHealthProvider
**Files:**
- Create: `src/State/EnvironmentHealthProvider.php`
- [ ] **Step 1: Create the provider**
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EnvironmentHealth;
use App\Repository\EnvironmentRepository;
use App\Service\DockerService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class EnvironmentHealthProvider implements ProviderInterface
{
public function __construct(
private EnvironmentRepository $environmentRepository,
private DockerService $dockerService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EnvironmentHealth
{
$id = $uriVariables['id'] ?? null;
$environment = $id ? $this->environmentRepository->find($id) : null;
if (null === $environment) {
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
}
$containerName = $environment->getContainerName();
$status = $this->dockerService->getContainerStatus($containerName);
$stats = $this->dockerService->getContainerStats($containerName);
$dto = new EnvironmentHealth();
$dto->status = $status['status'];
$dto->version = $status['version'];
$dto->startedAt = $status['startedAt'];
$dto->cpuPercent = $stats['cpuPercent'];
$dto->memoryUsage = $stats['memoryUsage'];
$dto->memoryLimit = $stats['memoryLimit'];
$dto->memoryPercent = $stats['memoryPercent'];
return $dto;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/State/EnvironmentHealthProvider.php
git commit -m "feat : add EnvironmentHealthProvider for detailed env metrics"
```
---
## Task 5: Frontend types and service
**Files:**
- Create: `frontend/services/dto/dashboard.ts`
- Create: `frontend/services/dashboard.ts`
- [ ] **Step 1: Create dashboard types**
```typescript
type DashboardEnvironment = {
id: number
name: string
status: string
version: string
}
type DashboardApplication = {
name: string
slug: string
giteaUrl?: string
environments: DashboardEnvironment[]
}
type DashboardResponse = {
applications: DashboardApplication[]
}
type EnvironmentHealth = {
status: string
version: string
startedAt: string
cpuPercent: number
memoryUsage: string
memoryLimit: string
memoryPercent: number
}
```
- [ ] **Step 2: Create dashboard service**
```typescript
import type { DashboardResponse, EnvironmentHealth } from './dto/dashboard'
export function getDashboard(): Promise<DashboardResponse> {
return useApi().get<DashboardResponse>('/dashboard', undefined, {
toast: false,
})
}
export function getEnvironmentHealth(envId: number): Promise<EnvironmentHealth> {
return useApi().get<EnvironmentHealth>(`/environments/${envId}/health`, undefined, {
toast: false,
})
}
```
- [ ] **Step 3: Commit**
```bash
git add frontend/services/dto/dashboard.ts frontend/services/dashboard.ts
git commit -m "feat : add frontend dashboard types and service"
```
---
## Task 6: i18n translations
**Files:**
- Modify: `frontend/i18n/locales/fr.json`
- [ ] **Step 1: Add dashboard translations**
Add a new `dashboard` section (replace the existing one which only has `title`):
```json
{
"dashboard": {
"title": "Dashboard",
"description": "Vue d'ensemble du SI",
"refresh": "Actualiser",
"status": {
"running": "En ligne",
"exited": "Arrete",
"restarting": "Redemarrage",
"not_found": "Introuvable"
}
}
}
```
Add a `health` sub-object inside the `environments` section:
```json
{
"environments": {
"...existing keys...",
"health": {
"title": "Sante du container",
"status": "Statut",
"version": "Version",
"uptime": "Uptime",
"cpu": "CPU",
"memory": "Memoire",
"noData": "Aucune donnee disponible"
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat : add i18n translations for dashboard and health"
```
---
## Task 7: Dashboard page
**Files:**
- Create: `frontend/pages/dashboard.vue`
- [ ] **Step 1: Create the dashboard page**
```vue
<script setup lang="ts">
import type { DashboardResponse } from '~/services/dto/dashboard'
import { getDashboard } from '~/services/dashboard'
const { t } = useI18n()
const data = ref<DashboardResponse | null>(null)
const loading = ref(true)
async function loadDashboard() {
loading.value = true
try {
data.value = await getDashboard()
} finally {
loading.value = false
}
}
function statusClass(status: string): string {
switch (status) {
case 'running': return 'bg-green-100 text-green-700'
case 'exited': return 'bg-red-100 text-red-700'
case 'restarting': return 'bg-orange-100 text-orange-700'
default: return 'bg-neutral-100 text-neutral-500'
}
}
function statusLabel(status: string): string {
const key = `dashboard.status.${status}`
return t(key)
}
onMounted(loadDashboard)
</script>
<template>
<div class="px-4 py-8 sm:px-8 lg:px-16">
<div class="flex items-center justify-between pb-6">
<h1 class="text-2xl font-bold text-primary-500 sm:text-4xl">{{ t('dashboard.title') }}</h1>
<MalioButton
:label="t('dashboard.refresh')"
variant="secondary"
icon-name="mdi:refresh"
icon-position="left"
:loading="loading"
@click="loadDashboard"
/>
</div>
<!-- Loading -->
<div v-if="loading && !data" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div v-for="i in 3" :key="i" class="rounded-lg bg-tertiary-500 p-5 animate-pulse">
<div class="h-6 bg-neutral-300 rounded w-1/3 mb-4" />
<div class="h-4 bg-neutral-300 rounded w-2/3 mb-2" />
<div class="h-4 bg-neutral-300 rounded w-1/2" />
</div>
</div>
<!-- Dashboard cards -->
<div v-else-if="data" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<NuxtLink
v-for="app in data.applications"
:key="app.slug"
:to="`/applications/${app.slug}`"
class="rounded-lg bg-tertiary-500 p-5 transition hover:shadow-md"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-neutral-900">{{ app.name }}</h3>
<a
v-if="app.giteaUrl"
:href="app.giteaUrl"
target="_blank"
class="text-neutral-400 hover:text-primary-500"
@click.stop
>
<Icon name="mdi:open-in-new" size="18" />
</a>
</div>
<div v-for="env in app.environments" :key="env.id" class="flex items-center justify-between py-2 border-t border-neutral-200 first:border-t-0">
<span class="text-sm text-neutral-700">{{ env.name }}</span>
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-neutral-400">{{ env.version }}</span>
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="statusClass(env.status)"
>
{{ statusLabel(env.status) }}
</span>
</div>
</div>
<div v-if="!app.environments.length" class="text-sm text-neutral-400">
{{ t('applications.card.noEnvironments') }}
</div>
</NuxtLink>
</div>
</div>
</template>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/pages/dashboard.vue
git commit -m "feat : add dashboard page with container status overview"
```
---
## Task 8: Sidebar link + redirect
**Files:**
- Modify: `frontend/layouts/default.vue`
- Modify: `frontend/middleware/auth.global.ts`
- [ ] **Step 1: Add Dashboard link to sidebar**
In `frontend/layouts/default.vue`, find the existing `SidebarLink` for applications (line ~41) and add the Dashboard link BEFORE it:
```vue
<SidebarLink
to="/dashboard"
icon="mdi:view-dashboard"
:label="$t('dashboard.title')"
:collapsed="sidebarIsCollapsed"
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
to="/applications"
icon="mdi:apps"
:label="$t('applications.title')"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
```
Note: move the `border-t` class from the applications link to the dashboard link (first item gets the border).
- [ ] **Step 2: Update auth middleware redirect**
In `frontend/middleware/auth.global.ts`, change the redirect from `/applications` to `/dashboard`:
Change:
```typescript
if (auth.isAuthenticated && (isLogin || to.path === '/')) {
return navigateTo('/applications')
}
```
To:
```typescript
if (auth.isAuthenticated && (isLogin || to.path === '/')) {
return navigateTo('/dashboard')
}
```
- [ ] **Step 3: Commit**
```bash
git add frontend/layouts/default.vue frontend/middleware/auth.global.ts
git commit -m "feat : add Dashboard to sidebar and redirect / to /dashboard"
```
---
## Task 9: Health metrics on detail page
**Files:**
- Modify: `frontend/pages/applications/[slug].vue`
- [ ] **Step 1: Add imports and state**
At the top of `<script setup>`, add the import after existing ones:
```typescript
import type { EnvironmentHealth } from '~/services/dto/dashboard'
import { getEnvironmentHealth } from '~/services/dashboard'
```
Add state after the deploy-related refs:
```typescript
// Health data per env
const healthByEnvId = ref<Record<number, EnvironmentHealth>>({})
const loadingHealth = ref(false)
```
- [ ] **Step 2: Add health loading function**
Add after the deploy functions:
```typescript
async function loadHealthData() {
if (!application.value?.environments?.length) return
loadingHealth.value = true
try {
const promises = application.value.environments.map(async (env) => {
try {
const health = await getEnvironmentHealth(env.id!)
healthByEnvId.value[env.id!] = health
} catch {
// silently ignore individual env health failures
}
})
await Promise.all(promises)
} finally {
loadingHealth.value = false
}
}
```
Update `loadApplication` to also load health after the app data:
```typescript
async function loadApplication() {
loading.value = true
try {
application.value = await getApplication(slug)
} finally {
loading.value = false
}
loadHealthData()
}
```
- [ ] **Step 3: Add helper function for uptime**
```typescript
function formatUptime(startedAt: string): string {
if (!startedAt) return '-'
const start = new Date(startedAt)
const now = new Date()
const diffMs = now.getTime() - start.getTime()
const days = Math.floor(diffMs / 86400000)
const hours = Math.floor((diffMs % 86400000) / 3600000)
const minutes = Math.floor((diffMs % 3600000) / 60000)
if (days > 0) return `${days}j ${hours}h`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
function statusClass(status: string): string {
switch (status) {
case 'running': return 'bg-green-100 text-green-700'
case 'exited': return 'bg-red-100 text-red-700'
case 'restarting': return 'bg-orange-100 text-orange-700'
default: return 'bg-neutral-100 text-neutral-500'
}
}
```
- [ ] **Step 4: Add health block to environment cards**
In the template, inside each environment card (`<div v-for="env in application.environments" ...>`), add a health metrics block AFTER the log files section and BEFORE the edit/delete buttons div:
```vue
<!-- Health metrics -->
<div v-if="healthByEnvId[env.id!]" class="mt-4 border-t border-neutral-200 pt-3">
<p class="text-xs font-semibold uppercase tracking-wider text-neutral-400 mb-3">{{ t('environments.health.title') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<p class="text-xs text-neutral-400">{{ t('environments.health.status') }}</p>
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
:class="statusClass(healthByEnvId[env.id!].status)"
>
{{ t(`dashboard.status.${healthByEnvId[env.id!].status}`) }}
</span>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.health.version') }}</p>
<p class="text-sm font-mono text-neutral-800 mt-1">{{ healthByEnvId[env.id!].version || '-' }}</p>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.health.uptime') }}</p>
<p class="text-sm text-neutral-800 mt-1">{{ formatUptime(healthByEnvId[env.id!].startedAt) }}</p>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.health.cpu') }}</p>
<p class="text-sm text-neutral-800 mt-1">{{ healthByEnvId[env.id!].cpuPercent }}%</p>
</div>
<div class="col-span-2">
<p class="text-xs text-neutral-400">{{ t('environments.health.memory') }}</p>
<p class="text-sm text-neutral-800 mt-1">
{{ healthByEnvId[env.id!].memoryUsage }} / {{ healthByEnvId[env.id!].memoryLimit }}
<span class="text-neutral-400">({{ healthByEnvId[env.id!].memoryPercent }}%)</span>
</p>
</div>
</div>
</div>
```
- [ ] **Step 5: Commit**
```bash
git add frontend/pages/applications/[slug].vue
git commit -m "feat : add health metrics block on environment detail"
```
---
## Task 10: Build and verify
- [ ] **Step 1: Clear Symfony cache**
```bash
docker exec -t -u www-data php-central-fpm php bin/console cache:clear
```
- [ ] **Step 2: Build frontend**
```bash
make build-nuxtJS
```
- [ ] **Step 3: Verify API endpoints**
```bash
# Login
docker exec -t php-central-fpm curl -s -c /tmp/cookies -X POST http://nginx/api/login_check \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}'
# Dashboard endpoint
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/dashboard
# Environment health endpoint (use actual env id)
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/environments/9/health
```
- [ ] **Step 4: Final commit if adjustments needed**
```bash
git add -A
git commit -m "fix : adjustments from end-to-end testing"
```