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"
```

View File

@@ -0,0 +1,172 @@
# Phase 1 — Gestion des applications et environnements
## Contexte
Central est un outil d'ops pour l'equipe technique Malio. Il gere les applications du SI deployees en Docker sur une meme machine (recette + prod).
Aujourd'hui les applications gerees sont definies en YAML (`config/applications.yaml`) avec des chemins maintenance en variables d'env. Cette phase remplace ce systeme statique par une gestion dynamique en base de donnees avec une UI complete.
## Roadmap globale
- **Phase 1** (ce spec) : Modele de donnees apps/envs + UI CRUD
- **Phase 2** : Deploiement de version, dashboard sante, logs
- **Phase 3** : Historique deploys, rollback, gestion containers, notifications
## Modele de donnees
### Entity `Application`
| Champ | Type | Contraintes |
|-------|------|-------------|
| `id` | int | PK, auto-generated |
| `name` | string (255) | not null |
| `slug` | string (255) | not null, unique |
| `registryImage` | string (255) | not null, ex: `gitea.malio.fr/malio-dev/sirh` |
| `description` | text | nullable |
| `giteaUrl` | string (255) | nullable |
| `createdAt` | DateTimeImmutable | not null, set on construct |
- Relation : `OneToMany` vers `Environment` (cascade persist + remove, orphanRemoval)
### Entity `Environment`
| Champ | Type | Contraintes |
|-------|------|-------------|
| `id` | int | PK, auto-generated |
| `name` | string (255) | not null, ex: "production", "recette" |
| `containerName` | string (255) | not null, ex: "sirh-app" |
| `deployScriptPath` | string (255) | not null |
| `maintenanceFilePath` | string (255) | not null |
| `appUrl` | string (255) | nullable |
| `application` | ManyToOne | not null, vers Application |
- Relation : `OneToMany` vers `LogFile` (cascade persist + remove, orphanRemoval)
### Entity `LogFile`
| Champ | Type | Contraintes |
|-------|------|-------------|
| `id` | int | PK, auto-generated |
| `label` | string (255) | not null, ex: "prod", "cron" |
| `path` | string (255) | not null |
| `environment` | ManyToOne | not null, vers Environment |
## API Endpoints
Tous les endpoints sont proteges par `ROLE_ADMIN`. Le slug est utilise comme identifiant URL pour Application.
### Application
| Route | Methode | Description | Provider/Processor |
|-------|---------|-------------|--------------------|
| `GET /api/applications` | GET | Liste des apps avec envs et logfiles | Doctrine (default) |
| `POST /api/applications` | POST | Creer une app | Doctrine (default) |
| `GET /api/applications/{slug}` | GET | Detail d'une app avec envs | Doctrine (default) |
| `PATCH /api/applications/{slug}` | PATCH | Modifier une app | Doctrine (default) |
| `DELETE /api/applications/{slug}` | DELETE | Supprimer une app (cascade envs) | Doctrine (default) |
Groupes de serialisation :
- Lecture : `app:read` (tous les champs + envs embarques)
- Ecriture : `app:write` (name, slug, registryImage, description, giteaUrl)
### Environment
| Route | Methode | Description | Provider/Processor |
|-------|---------|-------------|--------------------|
| `POST /api/applications/{slug}/environments` | POST | Ajouter un env a une app | Custom processor pour lier a l'app |
| `PATCH /api/environments/{id}` | PATCH | Modifier un env | Doctrine (default) |
| `DELETE /api/environments/{id}` | DELETE | Supprimer un env | Doctrine (default) |
Groupes de serialisation :
- Lecture : `env:read` (tous les champs + logfiles embarques)
- Ecriture : `env:write` (name, containerName, deployScriptPath, maintenanceFilePath, appUrl, logFiles)
Les LogFiles sont geres en embedded dans l'environnement : envoyes dans le body du POST/PATCH de l'env, pas d'endpoint separe.
### Maintenance
| Route | Methode | Description |
|-------|---------|-------------|
| `POST /api/environments/{id}/maintenance` | POST | Toggle maintenance (body: `{ "maintenance": true/false }`) |
Remplace l'ancien endpoint `POST /api/applications/{slug}/maintenance`. Le processor cree ou supprime le fichier maintenance sur le filesystem.
## Frontend
### Page liste — `/applications`
- Grille des applications (cards)
- Chaque card : nom, description (tronquee), nombre d'environnements, lien Gitea (icone externe)
- Bouton "Ajouter une application" en haut
- Clic sur une card → navigation vers `/applications/{slug}`
### Page detail — `/applications/{slug}`
**Section haute : infos application**
- Nom, description, image registry, lien Gitea
- Bouton editer → formulaire inline ou modale avec les champs app:write
- Bouton supprimer (avec confirmation)
**Section basse : environnements**
- Liste des environnements de l'app
- Chaque env affiche : nom, container, URL (lien cliquable), statut maintenance (badge)
- Actions par env : editer, supprimer, toggle maintenance
- Sous chaque env : liste des fichiers de log configures (label + path)
- Bouton "Ajouter un environnement" → formulaire avec champs env + logfiles dynamiques (ajouter/supprimer des lignes de log)
### Sidebar
- Lien "Applications" ajoute dans la sidebar (remplace ou complete le lien Dashboard actuel)
### Services frontend
- `services/applications.ts` : CRUD applications
- `services/environments.ts` : CRUD environnements + toggle maintenance
- `services/dto/application.ts` : types TypeScript
- `services/dto/environment.ts` : types TypeScript (inclut LogFile)
### i18n
- Nouvelles cles dans `fr.json` pour toutes les labels, messages de succes/erreur, confirmations de suppression
## Migration des donnees existantes
### Ce qui est cree
Une DataFixture (ou migration SQL) cree les 3 apps existantes :
**SIRH**
- slug: `sirh`, image: `gitea.malio.fr/malio-dev/sirh`
- Env prod : container `sirh-app`, deploy `/home/m-tristan/workspace/SIRH/deploy/docker/deploy.sh`
- Env recette : container `sirh-test-app` (si existant)
**Lesstime**
- slug: `lesstime`, image: `gitea.malio.fr/malio-dev/lesstime`
- Env prod : container `lesstime-app`
**Inventory**
- slug: `inventory`, image: `gitea.malio.fr/malio-dev/inventory`
- Env prod : container `inventory-app`
- Env recette : container `inventory-test-app`
Les chemins exacts (deploy, maintenance, logs) seront renseignes lors de l'implementation en inspectant chaque projet.
### Ce qui est supprime
- `config/applications.yaml`
- Variables d'env `SIRH_MAINTENANCE_PATH`, `LESSTIME_MAINTENANCE_PATH`, `INVENTORY_MAINTENANCE_PATH`
- `src/ApiResource/ManagedApplication.php`
- `src/State/ManagedApplicationProvider.php`
- `src/State/MaintenanceToggleProcessor.php`
- `services/dto/managed-application.ts` (frontend)
- `services/managed-applications.ts` (frontend)
La page d'accueil actuelle (dashboard avec toggle maintenance) est remplacee par la nouvelle page liste/detail.
## Hors scope
- Deploiement de version (phase 2)
- Logs en temps reel (phase 2)
- Dashboard sante containers (phase 2)
- Historique de deploiements (phase 3)
- Gestion multi-roles (pas de ROLE_USER pour l'instant)

View File

@@ -0,0 +1,155 @@
# Phase 2a — Deploiement de version + Versions disponibles
## Contexte
Central gere les applications du SI Malio. La phase 1 a mis en place le CRUD des applications et environnements en BDD. Cette phase ajoute la capacite de deployer une version sur un environnement et de lister les versions disponibles sur le registry Gitea.
Chaque app a un `deploy.sh` qui prend un tag en argument, pull l'image Docker, lance les migrations, et gere le mode maintenance. Le script tourne sur la machine host. Central tourne en Docker et accede au host via le socket Docker monte.
## Architecture
### Variable d'environnement
`GITEA_API_TOKEN` : token global Gitea avec acces lecture aux packages. Configure dans `.env` (backend Symfony). Non stocke en BDD.
`GITEA_API_URL` : URL de base de l'API Gitea (ex: `https://gitea.malio.fr`). Configure dans `.env`.
### Service GiteaRegistryService
Classe PHP dans `src/Service/GiteaRegistryService.php`.
Responsabilite : appeler l'API Gitea pour lister les tags d'une image container.
API Gitea : `GET {GITEA_API_URL}/api/v1/packages/{owner}/container/{package}` avec header `Authorization: token {GITEA_API_TOKEN}`.
- Input : `registryImage` de l'Application (ex: `gitea.malio.fr/malio-dev/sirh`) — on extrait `owner` (`malio-dev`) et `package` (`sirh`) depuis cette string.
- Output : liste de tags tries par date de creation (plus recent en premier), chaque tag avec son nom et sa date.
- Gestion d'erreur : si l'API est inaccessible ou le token invalide, renvoyer une erreur claire.
### Service DeployService
Classe PHP dans `src/Service/DeployService.php`.
Responsabilite : executer le script de deploy d'un environnement.
- Input : entite `Environment` + tag a deployer
- Execute `deployScriptPath` avec le tag en argument via `Symfony\Component\Process\Process`
- Timeout : 300 secondes (5 min max pour un deploy)
- Capture stdout + stderr
- Output : objet avec `success` (bool), `output` (string), `exitCode` (int)
Le process tourne dans le container Central. Le `deploy.sh` est accessible car les dossiers des apps sont montes dans le container. Le script utilise `docker compose` qui fonctionne grace au socket Docker monte.
### Endpoints API
| Route | Methode | Description | Securite |
|-------|---------|-------------|----------|
| `GET /api/applications/{slug}/tags` | GET | Liste les tags disponibles sur le registry Gitea | ROLE_ADMIN |
| `POST /api/environments/{id}/deploy` | POST | Lance un deploy, body: `{ "tag": "v1.2.3" }` | ROLE_ADMIN |
#### GET /api/applications/{slug}/tags
Provider custom `TagListProvider` dans `src/State/`.
- Charge l'Application par slug
- Appelle `GiteaRegistryService` avec le `registryImage`
- Retourne la liste des tags
Response :
```json
{
"tags": [
{ "name": "v1.2.3", "date": "2026-04-05T10:00:00Z" },
{ "name": "v1.2.2", "date": "2026-04-01T08:00:00Z" },
{ "name": "latest", "date": "2026-04-05T10:00:00Z" }
]
}
```
#### POST /api/environments/{id}/deploy
Processor custom `DeployProcessor` dans `src/State/`.
- Charge l'Environment par id
- Valide que le tag est fourni
- Appelle `DeployService`
- Retourne le resultat
Response succes :
```json
{
"success": true,
"output": "==> Deploying sirh:v1.2.3...\n...\n==> Deployed v1.2.3",
"tag": "v1.2.3"
}
```
Response echec :
```json
{
"success": false,
"output": "Error: ...",
"tag": "v1.2.3"
}
```
### Docker
#### docker-compose.yml (dev)
Ajouter au service `php` :
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
Note : en dev les deploy.sh ne sont pas forcement accessibles depuis le container (chemins host). Le deploy ne fonctionnera pleinement qu'en prod. En dev on peut tester la liste des tags et le mecanisme, mais le deploy lui-meme pourra echouer (script introuvable). C'est acceptable.
#### docker-compose.yml (prod)
Ajouter au service `app` :
```yaml
volumes:
- /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
```
Le socket Docker + les dossiers deploy montes en read-only (le deploy.sh est execute, pas modifie). Le script execute `docker compose` qui passe par le socket.
Installer `docker-cli` dans le Dockerfile prod pour que les commandes `docker compose` fonctionnent depuis le container.
### Frontend
#### Modal de deploy
Sur la page detail (`/applications/{slug}`), chaque environnement affiche un bouton "Deployer".
Clic sur "Deployer" → ouvre une modal `AppModal` avec :
- Un select (ou liste) des tags disponibles, charges depuis `GET /api/applications/{slug}/tags`
- Loading skeleton pendant le chargement des tags
- Bouton "Deployer" qui POST sur `/api/environments/{id}/deploy`
- Pendant le deploy : le bouton passe en loading, on attend la reponse
- A la fin : affichage du resultat dans la modal
- Succes : message vert + output du script dans un bloc `<pre>`
- Echec : message rouge + output du script dans un bloc `<pre>`
#### Service frontend
`frontend/services/deploy.ts` :
- `getAvailableTags(slug: string)` : GET les tags
- `deploy(envId: number, tag: string)` : POST le deploy
`frontend/services/dto/deploy.ts` :
- Type `Tag` : `{ name: string, date: string }`
- Type `DeployResult` : `{ success: boolean, output: string, tag: string }`
#### i18n
Nouvelles cles dans `fr.json` pour la modal de deploy (titre, select, boutons, messages succes/echec).
## Hors scope
- Streaming temps reel du log de deploy
- Historique des deployments
- Rollback
- Version actuellement deployee (affichage)

View File

@@ -0,0 +1,147 @@
# Phase 2b — Dashboard sante
## Contexte
Central gere les applications du SI Malio avec leurs environnements. Le socket Docker est monte dans le container Central (phase 2a). Cette phase ajoute un dashboard de sante qui affiche l'etat des containers Docker de chaque environnement, et enrichit la page detail avec des metriques.
## Architecture
### Service DockerService
Classe PHP dans `src/Service/DockerService.php`.
Utilise `Symfony\Component\Process\Process` pour executer des commandes Docker CLI via le socket monte.
**Methode `getContainerStatus(string $containerName): array`**
Execute `docker inspect --format '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}' {containerName}`.
Retourne :
```php
[
'status' => 'running', // running | exited | restarting | not_found
'image' => 'gitea.malio.fr/malio-dev/sirh:v1.2.3',
'version' => 'v1.2.3', // tag extrait de l'image
'startedAt' => '2026-04-06T10:00:00Z',
]
```
Si le container n'existe pas ou la commande echoue, retourne `status: not_found` avec des valeurs vides.
Le tag/version est extrait en splitant l'image sur `:` — si pas de tag, retourne `latest`.
**Methode `getContainerStats(string $containerName): array`**
Execute `docker stats --no-stream --format '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}' {containerName}`.
Retourne :
```php
[
'cpuPercent' => 2.5,
'memoryUsage' => '150MiB',
'memoryLimit' => '2GiB',
'memoryPercent' => 7.3,
]
```
### Endpoints API
| Route | Methode | Description | Securite |
|-------|---------|-------------|----------|
| `GET /api/dashboard` | GET | Toutes les apps avec statut de chaque env | ROLE_ADMIN |
| `GET /api/environments/{id}/health` | GET | Statut + stats detaillees d'un env | ROLE_ADMIN |
#### GET /api/dashboard
Provider custom `DashboardProvider` dans `src/State/`.
Charge toutes les Applications avec leurs Environments via le repository. Pour chaque Environment, appelle `DockerService::getContainerStatus()`.
Response :
```json
{
"applications": [
{
"name": "SIRH",
"slug": "sirh",
"environments": [
{
"id": 1,
"name": "production",
"status": "running",
"version": "v1.2.3"
}
]
}
]
}
```
#### GET /api/environments/{id}/health
Provider custom `EnvironmentHealthProvider` dans `src/State/`.
Charge l'Environment par id, appelle `getContainerStatus()` + `getContainerStats()`.
Response :
```json
{
"status": "running",
"version": "v1.2.3",
"startedAt": "2026-04-06T10:00:00Z",
"cpuPercent": 2.5,
"memoryUsage": "150MiB",
"memoryLimit": "2GiB",
"memoryPercent": 7.3
}
```
### Frontend
#### Page dashboard — `/dashboard`
Nouvelle page. Grille 2 colonnes fixe (`grid-cols-1 lg:grid-cols-2`).
Une card par application (fond `bg-tertiary-500`, style existant).
Chaque card :
- Header : nom de l'app (lien vers `/applications/{slug}`)
- Body : une ligne par environnement avec :
- Nom de l'env
- Badge statut : vert `running`, rouge `exited`, orange `restarting`, gris `not_found`
- Version deployee (tag, en `font-mono`)
Bouton refresh en haut a droite du titre.
Appel API : `GET /api/dashboard` au chargement.
#### Page detail — `/applications/{slug}`
Enrichir chaque bloc d'environnement avec un sous-bloc de metriques :
- Statut container (badge, meme style que dashboard)
- Version deployee
- Uptime (calcule depuis `startedAt` — ex: "2j 5h")
- CPU %
- Memoire : usage / limit (ex: "150MiB / 2GiB — 7.3%")
Appel API : `GET /api/environments/{id}/health` pour chaque env au chargement de la page.
#### Sidebar
Ajouter un lien "Dashboard" (`/dashboard`, icone `mdi:view-dashboard`) au-dessus du lien "Applications" existant.
### Frontend services
- `frontend/services/dashboard.ts` : `getDashboard()` et `getEnvironmentHealth(envId)`
- `frontend/services/dto/dashboard.ts` : types TypeScript
### i18n
Nouvelles cles dans `fr.json` pour le dashboard (titre, statuts, metriques, refresh).
## Hors scope
- Polling automatique
- Alertes / notifications
- Historique de sante
- Restart count, ports, taille des logs