feat/ajout-de-fonctionnalites (#1)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
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:
File diff suppressed because it is too large
Load Diff
800
docs/superpowers/plans/2026-04-06-phase2a-deploy-versions.md
Normal file
800
docs/superpowers/plans/2026-04-06-phase2a-deploy-versions.md
Normal 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"
|
||||
```
|
||||
837
docs/superpowers/plans/2026-04-06-phase2b-dashboard-sante.md
Normal file
837
docs/superpowers/plans/2026-04-06-phase2b-dashboard-sante.md
Normal 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"
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user