18 Commits

Author SHA1 Message Date
gitea-actions
585155dbb2 chore: bump version to v0.1.12
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 9s
2026-04-06 14:23:27 +00:00
8f585b4be8 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>
2026-04-06 14:23:20 +00:00
gitea-actions
f80578c26a chore: bump version to v0.1.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-03 12:09:28 +00:00
6dd3b7c701 docs : README avec explication du mode maintenance
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:09:20 +02:00
gitea-actions
fb2691251a chore: bump version to v0.1.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-03 12:03:35 +00:00
94115a80f6 docs : retire ferme de la doc de deploiement
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:03:27 +02:00
gitea-actions
99d161921e chore: bump version to v0.1.9
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 17s
2026-04-03 11:49:41 +00:00
bf9f4aaa29 fix(docker) : retire volume ferme du docker-compose prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:49:36 +02:00
gitea-actions
7f797e307d chore: bump version to v0.1.8
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 19s
2026-04-03 11:33:56 +00:00
89465f5cd5 fix(docker) : créer les dossiers maintenance dans l'image prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:33:47 +02:00
gitea-actions
0e68d9dbe7 chore: bump version to v0.1.7
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 16s
2026-04-03 11:30:05 +00:00
19ac37fb3e chore : retire Ferme des applications managées
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:29:57 +02:00
gitea-actions
7e967a1649 chore: bump version to v0.1.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 15s
2026-04-03 11:19:34 +00:00
f5ab0335f9 Revert "feat : commande app:create-user pour créer des utilisateurs"
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
This reverts commit ad92a4c434.
2026-04-03 13:19:28 +02:00
gitea-actions
44e1e4a293 chore: bump version to v0.1.5
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-03 11:12:34 +00:00
ad92a4c434 feat : commande app:create-user pour créer des utilisateurs
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Permet de créer un user en prod sans SQL :
  php bin/console app:create-user <username> <password> --admin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:12:28 +02:00
gitea-actions
be12175e17 chore: bump version to v0.1.4
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-03 11:09:26 +00:00
e8fc85c173 fix : correctifs de sécurité et robustesse post-review
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- MeProvider : guard null user avec AccessDeniedHttpException
- MaintenanceToggleProcessor : vérification des opérations filesystem
- User : restreindre Get/GetCollection aux ROLE_ADMIN
- useAppVersion : corriger le path relatif '/version'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:09:14 +02:00
58 changed files with 6659 additions and 451 deletions

10
.env
View File

@@ -44,9 +44,7 @@ DEFAULT_URI=http://localhost
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> malio/maintenance ###
SIRH_MAINTENANCE_PATH=/var/www/maintenance/sirh/maintenance.on
LESSTIME_MAINTENANCE_PATH=/var/www/maintenance/lesstime/maintenance.on
INVENTORY_MAINTENANCE_PATH=/var/www/maintenance/inventory/maintenance.on
FERME_MAINTENANCE_PATH=/var/www/maintenance/ferme/maintenance.on
###< malio/maintenance ###
###> gitea ###
GITEA_API_URL=https://gitea.malio.fr
GITEA_API_TOKEN=change_me_in_env_local
###< gitea ###

102
README.md
View File

@@ -1,6 +1,6 @@
# Central
Application de gestion du SI Malio — gestion des applications, bases de données, mode maintenance, déploiements.
Application de supervision du SI Malio. Permet de piloter le mode maintenance de toutes les applications sans se connecter a chaque projet.
## Installation
@@ -10,9 +10,107 @@ make install
make fixtures
```
## Accès
## Acces
- Frontend : http://localhost:8084
- API : http://localhost:8084/api
- Dev Nuxt (hot reload) : http://localhost:3003
- Login : `admin` / `admin`
## Applications gerees
| Application | Slug | Port prod | Fichier maintenance |
|-------------|------|-----------|---------------------|
| SIRH | `sirh` | 8080 | `/var/www/sirh/maintenance.on` |
| Lesstime | `lesstime` | 8081 | `/var/www/lesstime/maintenance.on` |
| Inventory | `inventory` | 8082 | `/var/www/inventory/maintenance.on` |
La configuration est dans `config/applications.yaml`.
## Comment fonctionne la maintenance
### Architecture
```
Central (container Docker, port 8084)
|
| Volumes Docker :
| /var/www/sirh (hote) --> /var/www/maintenance/sirh (container)
| /var/www/lesstime (hote) --> /var/www/maintenance/lesstime (container)
| /var/www/inventory (hote) --> /var/www/maintenance/inventory (container)
|
| Quand l'admin clique "Activer maintenance" sur Lesstime :
|
v
API : POST /api/applications/lesstime/maintenance { "maintenance": true }
|
v
MaintenanceToggleProcessor
--> touch /var/www/maintenance/lesstime/maintenance.on (dans le container)
--> via le volume Docker, cree /var/www/lesstime/maintenance.on sur l'hote
|
v
Nginx de l'hote (reverse proxy de Lesstime) :
if (-f /var/www/lesstime/maintenance.on) --> return 503
--> sert /var/www/lesstime/public/maintenance.html
```
### Cote Central (ce projet)
1. `config/applications.yaml` definit les apps et leur `maintenance_path` (variable d'env)
2. Les variables d'env (`SIRH_MAINTENANCE_PATH`, etc.) pointent vers `/var/www/maintenance/{slug}/maintenance.on`
3. Le `docker-compose.yml` prod monte les dossiers de deploy des apps vers `/var/www/maintenance/`
4. `MaintenanceToggleProcessor` cree ou supprime le fichier `maintenance.on`
5. `ManagedApplicationProvider` lit `file_exists()` pour afficher l'etat actuel
### Cote application cible (SIRH, Lesstime, Inventory)
Chaque application doit avoir :
1. **Un `maintenance.html`** dans `/var/www/{app}/public/` sur le serveur (extrait automatiquement par `deploy.sh`)
2. **Un nginx reverse proxy sur l'hote** qui verifie l'existence du fichier `maintenance.on` :
```nginx
server {
server_name app.malio-dev.fr;
root /var/www/{app}/public;
if (-f /var/www/{app}/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance { rewrite ^(.*)$ /maintenance.html break; }
location = /maintenance.html { internal; }
location / {
proxy_pass http://127.0.0.1:{port};
# headers...
}
}
```
### Ajouter une nouvelle application
1. Ajouter l'entree dans `config/applications.yaml`
2. Ajouter la variable d'env `{APP}_MAINTENANCE_PATH` dans `.env` et `.env` prod
3. Ajouter le volume dans `docker-compose.yml` prod : `/var/www/{app}:/var/www/maintenance/{app}`
4. Configurer le nginx reverse proxy de l'hote pour la nouvelle app (voir ci-dessus)
5. S'assurer que `maintenance.html` existe dans `/var/www/{app}/public/`
## Stack
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4
- **Frontend** : Nuxt 4 (SPA), Vue 3, Tailwind CSS
- **Auth** : JWT HTTP-only cookie
- **Docker** : PHP-FPM + Nginx + Supervisor
## Deploiement
Voir `doc/deployment-docker.md` pour le guide complet.
```bash
cd /var/www/central
./deploy.sh # latest
./deploy.sh v0.1.5 # version specifique
```

View File

@@ -1,6 +0,0 @@
parameters:
app.managed_applications:
- { name: 'SIRH', slug: 'sirh', maintenance_path: '%env(SIRH_MAINTENANCE_PATH)%' }
- { name: 'Lesstime', slug: 'lesstime', maintenance_path: '%env(LESSTIME_MAINTENANCE_PATH)%' }
- { name: 'Inventory', slug: 'inventory', maintenance_path: '%env(INVENTORY_MAINTENANCE_PATH)%' }
- { name: 'Ferme', slug: 'ferme', maintenance_path: '%env(FERME_MAINTENANCE_PATH)%' }

View File

@@ -4,7 +4,6 @@ parameters:
imports:
- { resource: version.yaml }
- { resource: applications.yaml }
services:
# default configuration for services in *this* file

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.3'
app.version: '0.1.12'

View File

@@ -88,7 +88,6 @@ services:
- /var/www/sirh:/var/www/maintenance/sirh
- /var/www/lesstime:/var/www/maintenance/lesstime
- /var/www/inventory:/var/www/maintenance/inventory
- /var/www/ferme:/var/www/maintenance/ferme
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
@@ -129,7 +128,6 @@ ENCRYPTION_KEY=<generer avec: openssl rand -hex 32>
SIRH_MAINTENANCE_PATH=/var/www/maintenance/sirh/maintenance.on
LESSTIME_MAINTENANCE_PATH=/var/www/maintenance/lesstime/maintenance.on
INVENTORY_MAINTENANCE_PATH=/var/www/maintenance/inventory/maintenance.on
FERME_MAINTENANCE_PATH=/var/www/maintenance/ferme/maintenance.on
```
### 5. Generer les cles JWT
@@ -155,7 +153,7 @@ Central pilote les fichiers `maintenance.on` des autres projets via des volumes
Verifier que les dossiers existent :
```bash
ls -ld /var/www/sirh /var/www/lesstime /var/www/inventory /var/www/ferme
ls -ld /var/www/sirh /var/www/lesstime /var/www/inventory
```
Si Central ne peut pas ecrire `maintenance.on`, il faudra ajuster les permissions sur ces dossiers pour que le processus du conteneur puisse creer/supprimer ce fichier.

View File

@@ -25,6 +25,7 @@ services:
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG
- uploads_data:/var/www/html/var/uploads
- /var/run/docker.sock:/var/run/docker.sock
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:

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

View File

@@ -0,0 +1,126 @@
<template>
<Teleport v-if="isOpen" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
:class="maxWidthClass"
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
<slot name="title" />
</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
</div>
<!-- Body -->
<div class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<slot />
</div>
<!-- Footer -->
<div class="border-t border-neutral-100 px-4 py-4 sm:px-8">
<div class="flex justify-end gap-3">
<slot name="footer">
<MalioButton
:label="cancelLabel"
variant="tertiary"
@click="close"
/>
<MalioButton
:label="submitLabel"
:loading="loading"
@click="$emit('submit')"
/>
</slot>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: boolean
submitLabel?: string
cancelLabel?: string
loading?: boolean
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
}>(), {
submitLabel: 'Enregistrer',
cancelLabel: 'Annuler',
loading: false,
maxWidth: '2xl',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'submit'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
function close() {
isOpen.value = false
}
const maxWidthClass = computed(() => {
const map = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
}
return map[props.maxWidth]
})
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
.modal-leave-to > div:last-child {
transform: scale(0.97);
opacity: 0;
}
</style>

View File

@@ -6,7 +6,7 @@ export function useAppVersion() {
if (version.value) {
return version.value
}
const response = await api.get<{ version: string }>('version', {}, {
const response = await api.get<{ version: string }>('/version', {}, {
toast: false
})
version.value = response.version

View File

@@ -8,8 +8,21 @@
"delete": "Impossible de supprimer la ressource."
},
"applications": {
"activateMaintenance": "Impossible d'activer le mode maintenance.",
"deactivateMaintenance": "Impossible de désactiver le mode maintenance."
"create": "Erreur lors de la creation de l'application",
"update": "Erreur lors de la modification de l'application",
"delete": "Erreur lors de la suppression de l'application",
"load": "Erreur lors du chargement des applications"
},
"environments": {
"create": "Erreur lors de la creation de l'environnement",
"update": "Erreur lors de la modification de l'environnement",
"delete": "Erreur lors de la suppression de l'environnement",
"activateMaintenance": "Erreur lors de l'activation de la maintenance",
"deactivateMaintenance": "Erreur lors de la desactivation de la maintenance"
},
"deploy": {
"tags": "Erreur lors du chargement des versions",
"deploy": "Erreur lors du deploiement"
},
"auth": {
"login": "Identifiants invalides.",
@@ -19,8 +32,16 @@
},
"success": {
"applications": {
"activateMaintenance": "Le mode maintenance a été activé.",
"deactivateMaintenance": "Le mode maintenance a été désactivé."
"create": "Application creee avec succes",
"update": "Application modifiee avec succes",
"delete": "Application supprimee avec succes"
},
"environments": {
"create": "Environnement cree avec succes",
"update": "Environnement modifie avec succes",
"delete": "Environnement supprime avec succes",
"activateMaintenance": "Maintenance activee",
"deactivateMaintenance": "Maintenance desactivee"
},
"auth": {
"login": "Connexion réussie.",
@@ -28,30 +49,95 @@
}
},
"dashboard": {
"title": "Tableau de bord"
"title": "Dashboard",
"description": "Vue d'ensemble du SI",
"refresh": "Actualiser",
"status": {
"running": "En ligne",
"exited": "Arrete",
"restarting": "Redemarrage",
"not_found": "Introuvable"
}
},
"applications": {
"eyebrow": "Pilotage centralise",
"title": "Supervision des applications",
"description": "Active ou desactive le mode maintenance sans te connecter a chaque projet. Chaque action pilote le fichier de maintenance de l'application cible.",
"listTitle": "Applications managees",
"listDescription": "L'etat affiche correspond au trigger de maintenance present sur le serveur de production.",
"emptyTitle": "Aucune application disponible",
"emptyDescription": "La configuration backend ne retourne encore aucune application geree.",
"status": {
"active": "Maintenance active",
"inactive": "En ligne"
},
"title": "Applications",
"description": "Gerer les applications du SI",
"addButton": "Ajouter une application",
"emptyTitle": "Aucune application",
"emptyDescription": "Aucune application configuree pour le moment.",
"card": {
"activeDescription": "Les utilisateurs voient actuellement la page de maintenance de cette application.",
"inactiveDescription": "L'application repond normalement et le trigger de maintenance est absent.",
"triggerFile": "Fichier trigger maintenance.on"
"environments": "environnement | environnements",
"noEnvironments": "Aucun environnement configure"
},
"actions": {
"refresh": "Rafraichir",
"detail": {
"title": "Detail de l'application",
"registryImage": "Image registry",
"giteaUrl": "Depot Gitea",
"description": "Description",
"editButton": "Modifier",
"deleteButton": "Supprimer",
"deleteConfirm": "Etes-vous sur de vouloir supprimer cette application et tous ses environnements ?"
},
"form": {
"name": "Nom",
"slug": "Slug",
"registryImage": "Image registry",
"description": "Description",
"giteaUrl": "URL Gitea",
"save": "Enregistrer",
"cancel": "Annuler"
}
},
"environments": {
"title": "Environnements",
"addButton": "Ajouter un environnement",
"maintenance": {
"active": "Maintenance active",
"inactive": "En ligne",
"activate": "Activer la maintenance",
"deactivate": "Desactiver la maintenance",
"pending": "Mise a jour en cours"
"pending": "En cours..."
},
"editButton": "Modifier",
"deleteButton": "Supprimer",
"deleteConfirm": "Etes-vous sur de vouloir supprimer cet environnement ?",
"form": {
"name": "Nom",
"containerName": "Nom du container",
"deployScriptPath": "Chemin du script de deploiement",
"maintenanceFilePath": "Chemin du fichier de maintenance",
"appUrl": "URL de l'application",
"save": "Enregistrer",
"cancel": "Annuler"
},
"logFiles": {
"title": "Fichiers de log",
"addButton": "Ajouter un fichier de log",
"label": "Label",
"path": "Chemin",
"remove": "Retirer"
},
"health": {
"title": "Sante du container",
"status": "Statut",
"version": "Version",
"uptime": "Uptime",
"cpu": "CPU",
"memory": "Memoire",
"noData": "Aucune donnee disponible"
},
"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"
}
}
}

View File

@@ -39,13 +39,20 @@
</div>
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<SidebarLink
to="/"
icon="mdi:view-dashboard-outline"
label="Tableau de bord"
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()"
/>
</nav>
<div class="flex items-center justify-center p-4">

View File

@@ -10,7 +10,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/login')
}
if (isLogin && auth.isAuthenticated) {
return navigateTo('/')
if (auth.isAuthenticated && (isLogin || to.path === '/')) {
return navigateTo('/dashboard')
}
})

View File

@@ -570,29 +570,6 @@
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -5715,16 +5692,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -12683,6 +12650,15 @@
"url": "https://opencollective.com/svgo"
}
},
"node_modules/svgo/node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
@@ -12788,6 +12764,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",

View File

@@ -0,0 +1,601 @@
<script setup lang="ts">
import type { Application, ApplicationWrite, Environment, EnvironmentWrite } from '~/services/dto/application'
import { getApplication, updateApplication, deleteApplication } from '~/services/applications'
import { createEnvironment, updateEnvironment, deleteEnvironment, toggleMaintenance } from '~/services/environments'
import type { DeployResult } from '~/services/dto/deploy'
import { getAvailableTags, deploy } from '~/services/deploy'
import type { EnvironmentHealth } from '~/services/dto/dashboard'
import { getEnvironmentHealth } from '~/services/dashboard'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string
const application = ref<Application | null>(null)
const loading = ref(true)
const pendingMaintenanceByEnvId = ref<Record<number, boolean>>({})
// 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)
// Health data per env
const healthByEnvId = ref<Record<number, EnvironmentHealth>>({})
const loadingHealth = ref(false)
// App edit modal
const showAppModal = ref(false)
const editForm = ref<ApplicationWrite>({ name: '', slug: '', registryImage: '', description: '', giteaUrl: '' })
const isSubmittingApp = ref(false)
// Env modal
const showEnvModal = ref(false)
const editingEnvId = ref<number | null>(null)
const envForm = ref<EnvironmentWrite>({
name: '',
containerName: '',
deployScriptPath: '',
maintenanceFilePath: '',
appUrl: '',
logFiles: [],
})
const isSubmittingEnv = ref(false)
async function loadApplication() {
loading.value = true
try {
application.value = await getApplication(slug)
} finally {
loading.value = false
}
loadHealthData()
}
// Application edit
function openEditAppModal() {
if (!application.value) return
editForm.value = {
name: application.value.name,
slug: application.value.slug,
registryImage: application.value.registryImage,
description: application.value.description ?? '',
giteaUrl: application.value.giteaUrl ?? '',
}
showAppModal.value = true
}
async function saveApp() {
isSubmittingApp.value = true
try {
application.value = await updateApplication(slug, editForm.value)
showAppModal.value = false
if (editForm.value.slug !== slug) {
router.replace(`/applications/${editForm.value.slug}`)
}
} finally {
isSubmittingApp.value = false
}
}
async function handleDeleteApp() {
if (!confirm(t('applications.detail.deleteConfirm'))) return
await deleteApplication(slug)
router.push('/applications')
}
// Environment CRUD
function openCreateEnvModal() {
editingEnvId.value = null
envForm.value = { name: '', containerName: '', deployScriptPath: '', maintenanceFilePath: '', appUrl: '', logFiles: [] }
showEnvModal.value = true
}
function openEditEnvModal(env: Environment) {
editingEnvId.value = env.id!
envForm.value = {
name: env.name,
containerName: env.containerName,
deployScriptPath: env.deployScriptPath,
maintenanceFilePath: env.maintenanceFilePath,
appUrl: env.appUrl ?? '',
logFiles: env.logFiles.map(lf => ({ label: lf.label, path: lf.path })),
}
showEnvModal.value = true
}
async function saveEnv() {
isSubmittingEnv.value = true
try {
if (editingEnvId.value) {
await updateEnvironment(editingEnvId.value, envForm.value)
} else {
await createEnvironment(slug, envForm.value)
}
showEnvModal.value = false
await loadApplication()
} finally {
isSubmittingEnv.value = false
}
}
async function handleDeleteEnv(envId: number) {
if (!confirm(t('environments.deleteConfirm'))) return
await deleteEnvironment(envId)
await loadApplication()
}
async function handleToggleMaintenance(env: Environment) {
const envId = env.id!
pendingMaintenanceByEnvId.value[envId] = true
try {
await toggleMaintenance(envId, !env.maintenance)
await loadApplication()
} finally {
delete pendingMaintenanceByEnvId.value[envId]
}
}
function addLogFile() {
envForm.value.logFiles.push({ label: '', path: '' })
}
function removeLogFile(index: number) {
envForm.value.logFiles.splice(index, 1)
}
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
}
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
}
}
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'
}
}
const envModalTitle = computed(() =>
editingEnvId.value ? t('environments.editButton') : t('environments.addButton')
)
onMounted(loadApplication)
</script>
<template>
<div class="px-4 py-8 sm:px-8 lg:px-16">
<!-- Back link -->
<NuxtLink to="/applications" class="text-neutral-400 hover:text-primary-500 text-sm mb-6 inline-flex items-center gap-1">
<Icon name="mdi:arrow-left" size="16" />
{{ t('applications.title') }}
</NuxtLink>
<!-- Loading -->
<div v-if="loading" class="animate-pulse mt-4">
<div class="h-10 bg-neutral-200 rounded w-1/3 mb-4" />
<div class="h-4 bg-neutral-200 rounded w-2/3 mb-2" />
<div class="h-4 bg-neutral-200 rounded w-1/2" />
</div>
<template v-else-if="application">
<!-- Application header -->
<div class="flex items-start justify-between pb-6">
<div>
<h1 class="text-2xl font-bold text-primary-500 sm:text-4xl">{{ application.name }}</h1>
<p v-if="application.description" class="text-neutral-500 mt-2">{{ application.description }}</p>
</div>
<div class="flex gap-2">
<MalioButton
:label="t('applications.detail.editButton')"
variant="secondary"
icon-name="mdi:pencil"
icon-position="left"
@click="openEditAppModal"
/>
<MalioButton
:label="t('applications.detail.deleteButton')"
variant="danger"
icon-name="mdi:trash-can-outline"
icon-position="left"
@click="handleDeleteApp"
/>
</div>
</div>
<!-- Application info -->
<div class="rounded-lg bg-tertiary-500 p-5 mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-neutral-400">{{ t('applications.detail.registryImage') }} :</span>
<span class="text-neutral-800 ml-1 font-mono">{{ application.registryImage }}</span>
</div>
<div v-if="application.giteaUrl">
<span class="text-neutral-400">{{ t('applications.detail.giteaUrl') }} :</span>
<a :href="application.giteaUrl" target="_blank" class="text-primary-500 hover:underline ml-1">
{{ application.giteaUrl }}
</a>
</div>
</div>
</div>
<!-- Environments section -->
<div>
<div class="flex items-center justify-between pb-4">
<h2 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ t('environments.title') }}</h2>
<MalioButtonIcon
icon="mdi:plus"
aria-label="Retour"
@click="openCreateEnvModal"
/>
</div>
<!-- Environments list -->
<div v-if="!application.environments?.length" class="rounded-lg border border-neutral-200 bg-white p-6 text-center text-neutral-500">
{{ t('applications.card.noEnvironments') }}
</div>
<div v-for="env in application.environments" :key="env.id" class="rounded-lg bg-tertiary-500 p-5 mb-4">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-neutral-900">{{ env.name }}</h3>
<span
class="inline-block rounded-full px-3 py-1 text-xs font-semibold"
:class="env.maintenance
? 'bg-orange-100 text-orange-700'
: 'bg-green-100 text-green-700'"
>
{{ env.maintenance ? t('environments.maintenance.active') : t('environments.maintenance.inactive') }}
</span>
</div>
<p class="text-neutral-400 text-sm mt-1 font-mono">{{ env.containerName }}</p>
<a
v-if="env.appUrl"
:href="env.appUrl"
target="_blank"
class="text-primary-500 hover:underline text-sm mt-1 inline-flex items-center gap-1"
>
{{ env.appUrl }}
<Icon name="mdi:open-in-new" size="14" />
</a>
</div>
<div class="flex gap-2">
<MalioButton
:label="t('environments.deploy.button')"
icon-name="mdi:rocket-launch-outline"
icon-position="left"
@click="openDeployModal(env)"
/>
<MalioButton
:label="pendingMaintenanceByEnvId[env.id!]
? t('environments.maintenance.pending')
: env.maintenance
? t('environments.maintenance.deactivate')
: t('environments.maintenance.activate')"
:variant="env.maintenance ? 'danger' : 'primary'"
:icon-name="env.maintenance ? 'mdi:shield-check-outline' : 'mdi:alert-outline'"
icon-position="left"
:loading="!!pendingMaintenanceByEnvId[env.id!]"
@click="handleToggleMaintenance(env)"
/>
</div>
</div>
<!-- Log files -->
<div v-if="env.logFiles.length" class="mt-4 border-t border-neutral-200 pt-3">
<p class="text-xs font-semibold uppercase tracking-wider text-neutral-400 mb-2">{{ t('environments.logFiles.title') }}</p>
<div v-for="lf in env.logFiles" :key="lf.id" class="text-sm text-neutral-700 flex gap-2">
<span class="font-medium">{{ lf.label }} :</span>
<span class="font-mono text-neutral-400">{{ lf.path }}</span>
</div>
</div>
<!-- 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>
<div class="flex justify-center gap-4 mt-4">
<MalioButton
:label="t('environments.editButton')"
variant="secondary"
icon-name="mdi:pencil"
icon-position="left"
@click="openEditEnvModal(env)"
/>
<MalioButton
:label="t('environments.deleteButton')"
variant="danger"
icon-name="mdi:trash-can-outline"
icon-position="left"
@click="handleDeleteEnv(env.id!)"
/>
</div>
</div>
</div>
</template>
<!-- Edit application modal -->
<AppModal
v-model="showAppModal"
:submit-label="t('applications.form.save')"
:cancel-label="t('applications.form.cancel')"
:loading="isSubmittingApp"
@submit="saveApp"
>
<template #title>{{ t('applications.detail.editButton') }}</template>
<form @submit.prevent="saveApp" class="space-y-4">
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioInputText
v-model="editForm.name"
:label="t('applications.form.name')"
required
/>
<MalioInputText
v-model="editForm.slug"
:label="t('applications.form.slug')"
required
/>
<MalioInputText
v-model="editForm.registryImage"
:label="t('applications.form.registryImage')"
required
/>
<MalioInputText
v-model="editForm.giteaUrl"
:label="t('applications.form.giteaUrl')"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ t('applications.form.description') }}</label>
<textarea
v-model="editForm.description"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:ring-2 focus:ring-secondary-500/20"
rows="3"
/>
</div>
</form>
</AppModal>
<!-- Environment modal -->
<AppModal
v-model="showEnvModal"
:submit-label="t('environments.form.save')"
:cancel-label="t('environments.form.cancel')"
:loading="isSubmittingEnv"
@submit="saveEnv"
>
<template #title>{{ envModalTitle }}</template>
<form @submit.prevent="saveEnv" class="space-y-4">
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioInputText
v-model="envForm.name"
:label="t('environments.form.name')"
required
/>
<MalioInputText
v-model="envForm.containerName"
:label="t('environments.form.containerName')"
required
/>
<MalioInputText
v-model="envForm.deployScriptPath"
:label="t('environments.form.deployScriptPath')"
required
/>
<MalioInputText
v-model="envForm.maintenanceFilePath"
:label="t('environments.form.maintenanceFilePath')"
required
/>
<MalioInputText
v-model="envForm.appUrl"
:label="t('environments.form.appUrl')"
/>
</div>
<!-- Log files -->
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-neutral-700">{{ t('environments.logFiles.title') }}</p>
<button type="button" @click="addLogFile" class="text-primary-500 hover:underline text-sm font-semibold">
+ {{ t('environments.logFiles.addButton') }}
</button>
</div>
<div v-for="(lf, index) in envForm.logFiles" :key="index" class="flex gap-2 mb-2">
<MalioInputText v-model="lf.label" :label="t('environments.logFiles.label')" groupClass="mt-0" inputClass="flex-1" required />
<MalioInputText v-model="lf.path" :label="t('environments.logFiles.path')" groupClass="mt-0" inputClass="flex-[2]" required />
<MalioButtonIcon
icon="mdi:delete-outline"
:aria-label="t('environments.logFiles.remove')"
variant="ghost"
icon-size="18"
button-class="text-red-500 hover:bg-red-50 hover:text-red-700 my-1"
@click="removeLogFile(index)"
/>
</div>
</div>
</form>
</AppModal>
<!-- 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>
</div>
</template>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { Application, ApplicationWrite } from '~/services/dto/application'
import { getApplications, createApplication } from '~/services/applications'
const { t } = useI18n()
const router = useRouter()
const applications = ref<Application[]>([])
const loading = ref(true)
const showCreateModal = ref(false)
const createForm = ref<ApplicationWrite>({
name: '',
slug: '',
registryImage: '',
description: '',
giteaUrl: '',
})
const isSubmitting = ref(false)
async function loadApplications() {
loading.value = true
try {
applications.value = await getApplications()
} finally {
loading.value = false
}
}
function openCreateModal() {
createForm.value = { name: '', slug: '', registryImage: '', description: '', giteaUrl: '' }
showCreateModal.value = true
}
async function handleCreate() {
isSubmitting.value = true
try {
const created = await createApplication(createForm.value)
showCreateModal.value = false
router.push(`/applications/${created.slug}`)
} finally {
isSubmitting.value = false
}
}
function generateSlug(name: string) {
createForm.value.slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
onMounted(loadApplications)
</script>
<template>
<div class="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('applications.title') }}</h1>
<MalioButton
:label="t('applications.addButton')"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateModal"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(280px,1fr))]">
<div v-for="i in 3" :key="i" class="rounded-lg bg-tertiary-500 p-5 animate-pulse">
<div class="h-5 bg-neutral-300 rounded w-1/2 mb-3" />
<div class="h-4 bg-neutral-300 rounded w-3/4 mb-2" />
<div class="h-4 bg-neutral-300 rounded w-1/3" />
</div>
</div>
<!-- Empty state -->
<div v-else-if="applications.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-center">
<h3 class="text-lg font-medium text-neutral-800">{{ t('applications.emptyTitle') }}</h3>
<p class="text-neutral-500 mt-1">{{ t('applications.emptyDescription') }}</p>
</div>
<!-- Application cards -->
<div v-else class="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(280px,1fr))]">
<NuxtLink
v-for="app in applications"
:key="app.slug"
:to="`/applications/${app.slug}`"
class="rounded-lg bg-tertiary-500 p-5 transition hover:shadow-md"
>
<div class="flex items-start justify-between">
<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>
<p v-if="app.description" class="text-neutral-500 text-sm mt-2 line-clamp-2">{{ app.description }}</p>
<p class="text-neutral-400 text-xs mt-4 flex items-center gap-1">
<Icon name="mdi:server" size="14" />
{{ app.environments?.length ?? 0 }} {{ t('applications.card.environments', app.environments?.length ?? 0) }}
</p>
</NuxtLink>
</div>
<!-- Create modal -->
<AppModal
v-model="showCreateModal"
:submit-label="t('applications.form.save')"
:cancel-label="t('applications.form.cancel')"
:loading="isSubmitting"
@submit="handleCreate"
>
<template #title>{{ t('applications.addButton') }}</template>
<form @submit.prevent="handleCreate" class="space-y-4">
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioInputText
v-model="createForm.name"
:label="t('applications.form.name')"
@update:model-value="generateSlug"
required
/>
<MalioInputText
v-model="createForm.slug"
:label="t('applications.form.slug')"
required
/>
<MalioInputText
v-model="createForm.registryImage"
:label="t('applications.form.registryImage')"
required
/>
<MalioInputText
v-model="createForm.giteaUrl"
:label="t('applications.form.giteaUrl')"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ t('applications.form.description') }}</label>
<textarea
v-model="createForm.description"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:ring-2 focus:ring-secondary-500/20"
rows="3"
/>
</div>
</form>
</AppModal>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<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 {
return t(`dashboard.status.${status}`)
}
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>
<MalioButtonIcon
icon="mdi:refresh"
:aria-label="t('dashboard.refresh')"
icon-size="22"
@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>

View File

@@ -1,176 +0,0 @@
<template>
<div class="flex h-full flex-col gap-6 pb-10">
<section class="flex flex-col gap-2">
<h1 class="text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ t('applications.title') }}
</h1>
<p class="max-w-3xl text-sm leading-6 text-neutral-500 sm:text-base">
{{ t('applications.description') }}
</p>
</section>
<section class="min-h-0 rounded-3xl border border-primary-500/15 bg-white shadow-sm">
<header class="flex flex-col gap-4 border-b border-primary-500/10 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-bold text-primary-500">
{{ t('applications.listTitle') }}
</h2>
<p class="mt-1 text-sm text-neutral-500">
{{ t('applications.listDescription') }}
</p>
</div>
<button
class="inline-flex items-center justify-center rounded-xl border border-primary-500/20 px-4 py-2 text-sm font-semibold text-primary-500 transition-colors hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
@click="loadApplications()"
>
<Icon
name="mdi:refresh"
size="18"
class="mr-2"
:class="loading ? 'animate-spin' : ''"
/>
{{ t('applications.actions.refresh') }}
</button>
</header>
<div v-if="loading" class="grid gap-4 p-6 xl:grid-cols-2">
<div
v-for="index in 4"
:key="index"
class="animate-pulse rounded-2xl border border-primary-500/10 p-5"
>
<div class="h-4 w-24 rounded bg-neutral-200" />
<div class="mt-4 h-7 w-40 rounded bg-neutral-200" />
<div class="mt-3 h-4 w-full rounded bg-neutral-100" />
<div class="mt-6 h-11 w-44 rounded-xl bg-neutral-200" />
</div>
</div>
<div v-else-if="applications.length === 0" class="px-6 py-12 text-center">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-tertiary-500 text-primary-500">
<Icon name="mdi:server-off" size="28" />
</div>
<h3 class="mt-4 text-lg font-bold text-primary-500">
{{ t('applications.emptyTitle') }}
</h3>
<p class="mt-2 text-sm text-neutral-500">
{{ t('applications.emptyDescription') }}
</p>
</div>
<div v-else class="grid gap-4 p-6 xl:grid-cols-2">
<article
v-for="application in applications"
:key="application.slug"
class="flex h-full min-w-0 flex-col rounded-2xl border p-5 transition-colors"
:class="application.maintenance ? 'border-red-200 bg-red-50/60' : 'border-emerald-200 bg-emerald-50/60'"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">
{{ application.slug }}
</p>
<h3 class="mt-2 break-words text-xl font-bold text-primary-500">
{{ application.name }}
</h3>
</div>
<span
class="inline-flex w-fit items-center rounded-full px-3 py-1 text-xs font-bold"
:class="application.maintenance ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'"
>
<span
class="mr-2 h-2.5 w-2.5 rounded-full"
:class="application.maintenance ? 'bg-red-500' : 'bg-emerald-500'"
/>
{{ application.maintenance ? t('applications.status.active') : t('applications.status.inactive') }}
</span>
</div>
<p class="mt-4 flex-1 text-sm leading-6 text-neutral-600">
{{
application.maintenance
? t('applications.card.activeDescription')
: t('applications.card.inactiveDescription')
}}
</p>
<div class="mt-6 flex flex-col gap-3 border-t border-black/5 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs font-medium uppercase tracking-[0.14em] text-neutral-400">
{{ t('applications.card.triggerFile') }}
</p>
<button
class="inline-flex w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto sm:min-w-[220px]"
:class="application.maintenance ? 'bg-red-600 hover:bg-red-700' : 'bg-primary-500 hover:bg-primary-600'"
:disabled="Boolean(pendingBySlug[application.slug])"
@click="toggleMaintenance(application)"
>
<Icon
:name="pendingBySlug[application.slug] ? 'mdi:loading' : (application.maintenance ? 'mdi:shield-check-outline' : 'mdi:alert-outline')"
size="18"
class="mr-2"
:class="pendingBySlug[application.slug] ? 'animate-spin' : ''"
/>
{{
pendingBySlug[application.slug]
? t('applications.actions.pending')
: application.maintenance
? t('applications.actions.deactivate')
: t('applications.actions.activate')
}}
</button>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { ManagedApplication } from '~/services/dto/managed-application'
import { getManagedApplications, setApplicationMaintenance } from '~/services/managed-applications'
const { t } = useI18n()
const applications = ref<ManagedApplication[]>([])
const loading = ref(true)
const pendingBySlug = ref<Record<string, boolean>>({})
async function loadApplications() {
loading.value = true
try {
applications.value = await getManagedApplications()
} finally {
loading.value = false
}
}
async function toggleMaintenance(application: ManagedApplication) {
pendingBySlug.value = {
...pendingBySlug.value,
[application.slug]: true
}
try {
const updatedApplication = await setApplicationMaintenance(application.slug, !application.maintenance)
applications.value = applications.value.map((item) => item.slug === updatedApplication.slug ? updatedApplication : item)
} finally {
pendingBySlug.value = {
...pendingBySlug.value,
[application.slug]: false
}
}
}
onMounted(() => {
loadApplications()
})
useHead({
title: 'Applications'
})
</script>

View File

@@ -0,0 +1,41 @@
import type { Application, ApplicationWrite } from './dto/application'
import { type HydraCollection, extractHydraMembers } from '~/utils/api'
export function getApplications(): Promise<Application[]> {
const api = useApi()
return api.get<Application[]>('/applications', undefined, {
toast: false,
}).then((response) => {
if (Array.isArray(response)) {
return response
}
return extractHydraMembers(response as HydraCollection<Application>)
})
}
export function getApplication(slug: string): Promise<Application> {
return useApi().get<Application>(`/applications/${slug}`, undefined, {
toast: false,
})
}
export function createApplication(data: ApplicationWrite): Promise<Application> {
return useApi().post<Application>('/applications', data, {
toastSuccessKey: 'success.applications.create',
toastErrorKey: 'errors.applications.create',
})
}
export function updateApplication(slug: string, data: Partial<ApplicationWrite>): Promise<Application> {
return useApi().patch<Application>(`/applications/${slug}`, data, {
toastSuccessKey: 'success.applications.update',
toastErrorKey: 'errors.applications.update',
})
}
export function deleteApplication(slug: string): Promise<void> {
return useApi().delete<void>(`/applications/${slug}`, undefined, {
toastSuccessKey: 'success.applications.delete',
toastErrorKey: 'errors.applications.delete',
})
}

View File

@@ -0,0 +1,13 @@
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,
})
}

View File

@@ -0,0 +1,13 @@
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,
})
}

View File

@@ -0,0 +1,46 @@
type LogFile = {
id?: number
label: string
path: string
}
type Environment = {
id?: number
'@id'?: string
name: string
containerName: string
deployScriptPath: string
maintenanceFilePath: string
appUrl?: string
logFiles: LogFile[]
maintenance: boolean
}
type EnvironmentWrite = {
name: string
containerName: string
deployScriptPath: string
maintenanceFilePath: string
appUrl?: string
logFiles: LogFile[]
}
type Application = {
id?: number
'@id'?: string
slug: string
name: string
registryImage: string
description?: string
giteaUrl?: string
createdAt?: string
environments?: Environment[]
}
type ApplicationWrite = {
name: string
slug: string
registryImage: string
description?: string
giteaUrl?: string
}

View File

@@ -0,0 +1,27 @@
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
}

View File

@@ -0,0 +1,9 @@
type TagListResponse = {
tags: string[]
}
type DeployResult = {
success: boolean
output: string
tag: string
}

View File

@@ -1,10 +0,0 @@
export type ManagedApplication = {
slug: string
name: string
maintenance: boolean
}
export type ManagedApplicationCollection = {
'hydra:member'?: ManagedApplication[]
member?: ManagedApplication[]
}

View File

@@ -0,0 +1,36 @@
import type { Environment, EnvironmentWrite } from './dto/application'
export function createEnvironment(appSlug: string, data: EnvironmentWrite): Promise<Environment> {
return useApi().post<Environment>(`/applications/${appSlug}/environments`, data, {
toastSuccessKey: 'success.environments.create',
toastErrorKey: 'errors.environments.create',
})
}
export function updateEnvironment(id: number, data: Partial<EnvironmentWrite>): Promise<Environment> {
return useApi().patch<Environment>(`/environments/${id}`, data, {
toastSuccessKey: 'success.environments.update',
toastErrorKey: 'errors.environments.update',
})
}
export function deleteEnvironment(id: number): Promise<void> {
return useApi().delete<void>(`/environments/${id}`, undefined, {
toastSuccessKey: 'success.environments.delete',
toastErrorKey: 'errors.environments.delete',
})
}
export function toggleMaintenance(id: number, maintenance: boolean): Promise<Environment> {
const successKey = maintenance
? 'success.environments.activateMaintenance'
: 'success.environments.deactivateMaintenance'
const errorKey = maintenance
? 'errors.environments.activateMaintenance'
: 'errors.environments.deactivateMaintenance'
return useApi().post<Environment>(`/environments/${id}/maintenance`, { maintenance }, {
toastSuccessKey: successKey,
toastErrorKey: errorKey,
})
}

View File

@@ -1,33 +0,0 @@
import type { ManagedApplication, ManagedApplicationCollection } from './dto/managed-application'
function normalizeManagedApplications(response: ManagedApplication[] | ManagedApplicationCollection): ManagedApplication[] {
if (Array.isArray(response)) {
return response
}
return response['hydra:member'] ?? response.member ?? []
}
export async function getManagedApplications(): Promise<ManagedApplication[]> {
const api = useApi()
const response = await api.get<ManagedApplication[] | ManagedApplicationCollection>('/applications')
return normalizeManagedApplications(response)
}
export function setApplicationMaintenance(slug: string, maintenance: boolean) {
const api = useApi()
return api.post<ManagedApplication>(
`/applications/${slug}/maintenance`,
{ maintenance },
{
toastSuccessKey: maintenance
? 'success.applications.activateMaintenance'
: 'success.applications.deactivateMaintenance',
toastErrorKey: maintenance
? 'errors.applications.activateMaintenance'
: 'errors.applications.deactivateMaintenance'
}
)
}

View File

@@ -40,7 +40,7 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
nginx supervisor docker.io \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
@@ -73,7 +73,8 @@ RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \
&& chown -R www-data:www-data /var/www/html/var
/var/www/maintenance/sirh /var/www/maintenance/lesstime /var/www/maintenance/inventory \
&& chown -R www-data:www-data /var/www/html/var /var/www/maintenance
WORKDIR /var/www/html
EXPOSE 80

View File

@@ -8,10 +8,10 @@ services:
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
- /var/www/sirh:/var/www/maintenance/sirh
- /var/www/lesstime:/var/www/maintenance/lesstime
- /var/www/inventory:/var/www/maintenance/inventory
- /var/www/ferme:/var/www/maintenance/ferme
- /var/run/docker.sock:/var/run/docker.sock
- /var/www/sirh:/var/www/sirh
- /var/www/lesstime:/var/www/lesstime
- /var/www/inventory:/var/www/inventory
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260406113025 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE application (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, registry_image VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, gitea_url VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_A45BDDC1989D9B62 ON application (slug)');
$this->addSql('CREATE TABLE environment (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, container_name VARCHAR(255) NOT NULL, deploy_script_path VARCHAR(255) NOT NULL, maintenance_file_path VARCHAR(255) NOT NULL, app_url VARCHAR(255) DEFAULT NULL, application_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_4626DE223E030ACD ON environment (application_id)');
$this->addSql('CREATE TABLE log_file (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, path VARCHAR(255) NOT NULL, environment_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_9DF1D865903E3A94 ON log_file (environment_id)');
$this->addSql('ALTER TABLE environment ADD CONSTRAINT FK_4626DE223E030ACD FOREIGN KEY (application_id) REFERENCES application (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE log_file ADD CONSTRAINT FK_9DF1D865903E3A94 FOREIGN KEY (environment_id) REFERENCES environment (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE environment DROP CONSTRAINT FK_4626DE223E030ACD');
$this->addSql('ALTER TABLE log_file DROP CONSTRAINT FK_9DF1D865903E3A94');
$this->addSql('DROP TABLE application');
$this->addSql('DROP TABLE environment');
$this->addSql('DROP TABLE log_file');
}
}

View File

@@ -0,0 +1,24 @@
<?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 = [];
}

View File

@@ -0,0 +1,25 @@
<?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 = '';
}

View File

@@ -0,0 +1,29 @@
<?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;
}

View File

@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\State\ManagedApplicationProvider;
use App\State\MaintenanceToggleProcessor;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/applications',
normalizationContext: ['groups' => ['app:read']],
security: "is_granted('ROLE_ADMIN')",
provider: ManagedApplicationProvider::class,
),
new Get(
uriTemplate: '/applications/{slug}',
normalizationContext: ['groups' => ['app:read']],
security: "is_granted('ROLE_ADMIN')",
provider: ManagedApplicationProvider::class,
),
new Post(
uriTemplate: '/applications/{slug}/maintenance',
normalizationContext: ['groups' => ['app:read']],
denormalizationContext: ['groups' => ['app:write']],
security: "is_granted('ROLE_ADMIN')",
provider: ManagedApplicationProvider::class,
processor: MaintenanceToggleProcessor::class,
),
],
)]
final class ManagedApplication
{
#[ApiProperty(identifier: true)]
#[Groups(['app:read'])]
public string $slug = '';
#[Groups(['app:read'])]
public string $name = '';
#[Groups(['app:read', 'app:write'])]
public bool $maintenance = false;
}

View File

@@ -0,0 +1,24 @@
<?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 = [];
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Application;
use App\Entity\Environment;
use App\Entity\LogFile;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@@ -16,6 +19,14 @@ class AppFixtures extends Fixture
) {}
public function load(ObjectManager $manager): void
{
$this->loadUsers($manager);
$this->loadApplications($manager);
$manager->flush();
}
private function loadUsers(ObjectManager $manager): void
{
$admin = new User();
$admin->setUsername('admin');
@@ -34,7 +45,77 @@ class AppFixtures extends Fixture
$userBob->setRoles(['ROLE_USER']);
$userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob'));
$manager->persist($userBob);
}
$manager->flush();
private function loadApplications(ObjectManager $manager): void
{
$sirh = new Application();
$sirh->setName('SIRH');
$sirh->setSlug('sirh');
$sirh->setRegistryImage('gitea.malio.fr/malio-dev/sirh');
$sirh->setDescription('Application de gestion des absences');
$sirh->setGiteaUrl('https://gitea.malio.fr/malio-dev/sirh');
$sirhProd = new Environment();
$sirhProd->setName('production');
$sirhProd->setContainerName('sirh-app');
$sirhProd->setDeployScriptPath('/home/m-tristan/workspace/SIRH/deploy/docker/deploy.sh');
$sirhProd->setMaintenanceFilePath('/home/m-tristan/workspace/SIRH/deploy/docker/maintenance.on');
$sirhProd->setAppUrl('http://sirh.malio-dev.fr');
$sirh->addEnvironment($sirhProd);
$sirhProdLog = new LogFile();
$sirhProdLog->setLabel('prod');
$sirhProdLog->setPath('/home/m-tristan/workspace/SIRH/var/log/prod.log');
$sirhProd->addLogFile($sirhProdLog);
$sirhCronLog = new LogFile();
$sirhCronLog->setLabel('cron');
$sirhCronLog->setPath('/home/m-tristan/workspace/SIRH/var/log/cron.log');
$sirhProd->addLogFile($sirhCronLog);
$manager->persist($sirh);
$lesstime = new Application();
$lesstime->setName('Lesstime');
$lesstime->setSlug('lesstime');
$lesstime->setRegistryImage('gitea.malio.fr/malio-dev/lesstime');
$lesstime->setDescription('Application de gestion du temps');
$lesstime->setGiteaUrl('https://gitea.malio.fr/malio-dev/lesstime');
$lesstimeProd = new Environment();
$lesstimeProd->setName('production');
$lesstimeProd->setContainerName('lesstime-app');
$lesstimeProd->setDeployScriptPath('/home/m-tristan/workspace/lesstime/deploy/docker/deploy.sh');
$lesstimeProd->setMaintenanceFilePath('/home/m-tristan/workspace/lesstime/deploy/docker/maintenance.on');
$lesstimeProd->setAppUrl('http://lesstime.malio-dev.fr');
$lesstime->addEnvironment($lesstimeProd);
$manager->persist($lesstime);
$inventory = new Application();
$inventory->setName('Inventory');
$inventory->setSlug('inventory');
$inventory->setRegistryImage('gitea.malio.fr/malio-dev/inventory');
$inventory->setDescription('Application de gestion des inventaires');
$inventory->setGiteaUrl('https://gitea.malio.fr/malio-dev/inventory');
$inventoryProd = new Environment();
$inventoryProd->setName('production');
$inventoryProd->setContainerName('inventory-app');
$inventoryProd->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy.sh');
$inventoryProd->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance.on');
$inventoryProd->setAppUrl('http://inventory.malio-dev.fr');
$inventory->addEnvironment($inventoryProd);
$inventoryRecette = new Environment();
$inventoryRecette->setName('recette');
$inventoryRecette->setContainerName('inventory-test-app');
$inventoryRecette->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy-test.sh');
$inventoryRecette->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance-test.on');
$inventoryRecette->setAppUrl('http://inventory-test.malio-dev.fr');
$inventory->addEnvironment($inventoryRecette);
$manager->persist($inventory);
}
}

189
src/Entity/Application.php Normal file
View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ApplicationRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['app:read']],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriVariables: ['slug'],
normalizationContext: ['groups' => ['app:read', 'app:detail']],
security: "is_granted('ROLE_ADMIN')",
),
new Post(
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
uriVariables: ['slug'],
security: "is_granted('ROLE_ADMIN')",
),
new Delete(
uriVariables: ['slug'],
security: "is_granted('ROLE_ADMIN')",
),
],
normalizationContext: ['groups' => ['app:read']],
denormalizationContext: ['groups' => ['app:write']],
)]
#[ORM\Entity(repositoryClass: ApplicationRepository::class)]
class Application
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['app:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['app:read', 'app:write'])]
private ?string $name = null;
#[ORM\Column(length: 255, unique: true)]
#[ApiProperty(identifier: true)]
#[Groups(['app:read', 'app:write'])]
private ?string $slug = null;
#[ORM\Column(length: 255)]
#[Groups(['app:read', 'app:write'])]
private ?string $registryImage = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['app:read', 'app:write'])]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['app:read', 'app:write'])]
private ?string $giteaUrl = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[Groups(['app:read'])]
private ?DateTimeImmutable $createdAt = null;
/** @var Collection<int, Environment> */
#[ORM\OneToMany(targetEntity: Environment::class, mappedBy: 'application', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['app:read', 'app:detail'])]
private Collection $environments;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->environments = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(string $slug): static
{
$this->slug = $slug;
return $this;
}
public function getRegistryImage(): ?string
{
return $this->registryImage;
}
public function setRegistryImage(string $registryImage): static
{
$this->registryImage = $registryImage;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getGiteaUrl(): ?string
{
return $this->giteaUrl;
}
public function setGiteaUrl(?string $giteaUrl): static
{
$this->giteaUrl = $giteaUrl;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
/** @return Collection<int, Environment> */
public function getEnvironments(): Collection
{
return $this->environments;
}
public function addEnvironment(Environment $environment): static
{
if (!$this->environments->contains($environment)) {
$this->environments->add($environment);
$environment->setApplication($this);
}
return $this;
}
public function removeEnvironment(Environment $environment): static
{
if ($this->environments->removeElement($environment)) {
if ($environment->getApplication() === $this) {
$environment->setApplication(null);
}
}
return $this;
}
}

198
src/Entity/Environment.php Normal file
View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\EnvironmentRepository;
use App\State\MaintenanceToggleProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/applications/{slug}/environments',
uriVariables: [
'slug' => new Link(fromClass: Application::class, fromProperty: 'environments'),
],
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
security: "is_granted('ROLE_ADMIN')",
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
),
new Post(
uriTemplate: '/environments/{id}/maintenance',
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['maintenance:write']],
processor: MaintenanceToggleProcessor::class,
),
],
normalizationContext: ['groups' => ['env:read']],
denormalizationContext: ['groups' => ['env:write']],
)]
#[ORM\Entity(repositoryClass: EnvironmentRepository::class)]
class Environment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['env:read', 'app:detail'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $name = null;
#[ORM\Column(length: 255)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $containerName = null;
#[ORM\Column(length: 255)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $deployScriptPath = null;
#[ORM\Column(length: 255)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $maintenanceFilePath = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $appUrl = null;
#[ORM\ManyToOne(targetEntity: Application::class, inversedBy: 'environments')]
#[ORM\JoinColumn(nullable: false)]
private ?Application $application = null;
/** @var Collection<int, LogFile> */
#[ORM\OneToMany(targetEntity: LogFile::class, mappedBy: 'environment', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private Collection $logFiles;
#[Groups(['env:read', 'app:detail'])]
private ?bool $maintenance = null;
public function __construct()
{
$this->logFiles = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getContainerName(): ?string
{
return $this->containerName;
}
public function setContainerName(string $containerName): static
{
$this->containerName = $containerName;
return $this;
}
public function getDeployScriptPath(): ?string
{
return $this->deployScriptPath;
}
public function setDeployScriptPath(string $deployScriptPath): static
{
$this->deployScriptPath = $deployScriptPath;
return $this;
}
public function getMaintenanceFilePath(): ?string
{
return $this->maintenanceFilePath;
}
public function setMaintenanceFilePath(string $maintenanceFilePath): static
{
$this->maintenanceFilePath = $maintenanceFilePath;
return $this;
}
public function getAppUrl(): ?string
{
return $this->appUrl;
}
public function setAppUrl(?string $appUrl): static
{
$this->appUrl = $appUrl;
return $this;
}
public function getApplication(): ?Application
{
return $this->application;
}
public function setApplication(?Application $application): static
{
$this->application = $application;
return $this;
}
/** @return Collection<int, LogFile> */
public function getLogFiles(): Collection
{
return $this->logFiles;
}
public function addLogFile(LogFile $logFile): static
{
if (!$this->logFiles->contains($logFile)) {
$this->logFiles->add($logFile);
$logFile->setEnvironment($this);
}
return $this;
}
public function removeLogFile(LogFile $logFile): static
{
if ($this->logFiles->removeElement($logFile)) {
if ($logFile->getEnvironment() === $this) {
$logFile->setEnvironment(null);
}
}
return $this;
}
public function getMaintenance(): bool
{
return file_exists((string) $this->maintenanceFilePath);
}
}

72
src/Entity/LogFile.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\LogFileRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: LogFileRepository::class)]
class LogFile
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['env:read', 'app:detail'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $label = null;
#[ORM\Column(length: 255)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $path = null;
#[ORM\ManyToOne(targetEntity: Environment::class, inversedBy: 'logFiles')]
#[ORM\JoinColumn(nullable: false)]
private ?Environment $environment = null;
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPath(): ?string
{
return $this->path;
}
public function setPath(string $path): static
{
$this->path = $path;
return $this;
}
public function getEnvironment(): ?Environment
{
return $this->environment;
}
public function setEnvironment(?Environment $environment): static
{
$this->environment = $environment;
return $this;
}
}

View File

@@ -28,9 +28,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['me:read']],
),
new Get(
security: "is_granted('ROLE_ADMIN')",
normalizationContext: ['groups' => ['user:list']],
),
new GetCollection(
security: "is_granted('ROLE_ADMIN')",
normalizationContext: ['groups' => ['user:list']],
),
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Application;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Application>
*/
class ApplicationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Application::class);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Environment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Environment>
*/
class EnvironmentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Environment::class);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\LogFile;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<LogFile>
*/
class LogFileRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, LogFile::class);
}
}

View File

@@ -0,0 +1,41 @@
<?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,
];
}
}

View File

@@ -0,0 +1,100 @@
<?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], '%'),
];
}
}

View File

@@ -0,0 +1,93 @@
<?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));
$bearerToken = $this->getBearerToken($owner, $package);
$url = sprintf('%s/v2/%s/%s/tags/list', $this->giteaApiUrl, $owner, $package);
$response = $this->httpClient->request('GET', $url, [
'headers' => [
'Authorization' => sprintf('Bearer %s', $bearerToken),
],
'timeout' => 10,
]);
$data = $response->toArray();
$tags = $data['tags'] ?? [];
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;
}
private function getBearerToken(string $owner, string $package): string
{
$tokenUrl = sprintf(
'%s/v2/token?service=container_registry&scope=repository:%s/%s:pull',
$this->giteaApiUrl,
$owner,
$package,
);
$response = $this->httpClient->request('GET', $tokenUrl, [
'auth_basic' => [$this->giteaApiToken, ''],
'timeout' => 10,
]);
$data = $response->toArray();
return $data['token'] ?? throw new \RuntimeException('Failed to obtain bearer token from Gitea registry.');
}
}

View File

@@ -0,0 +1,50 @@
<?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;
}
}

View File

@@ -0,0 +1,47 @@
<?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;
}
}

View File

@@ -0,0 +1,45 @@
<?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;
}
}

View File

@@ -6,58 +6,41 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ManagedApplication;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use App\Entity\Environment;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class MaintenanceToggleProcessor implements ProcessorInterface
{
/**
* @param list<array{name: string, slug: string, maintenance_path: string}> $managedApplications
* @param Environment $data
*/
public function __construct(
#[Autowire('%app.managed_applications%')]
private array $managedApplications,
) {}
/**
* @param ManagedApplication $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ManagedApplication
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Environment
{
$slug = $uriVariables['slug'] ?? '';
$appConfig = null;
$maintenancePath = $data->getMaintenanceFilePath();
foreach ($this->managedApplications as $app) {
if ($app['slug'] === $slug) {
$appConfig = $app;
break;
}
if (null === $maintenancePath) {
throw new BadRequestHttpException('Maintenance file path is not configured for this environment.');
}
if (null === $appConfig) {
throw new NotFoundHttpException(sprintf('Application "%s" not found.', $slug));
}
$requestData = $context['request']?->toArray() ?? [];
$enableMaintenance = $requestData['maintenance'] ?? false;
$maintenancePath = $appConfig['maintenance_path'];
if ($data->maintenance) {
if ($enableMaintenance) {
$directory = dirname($maintenancePath);
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
if (!is_dir($directory) && !mkdir($directory, 0755, true)) {
throw new \RuntimeException(sprintf('Cannot create directory "%s".', $directory));
}
touch($maintenancePath);
if (!touch($maintenancePath)) {
throw new \RuntimeException(sprintf('Cannot create maintenance file at "%s".', $maintenancePath));
}
} elseif (file_exists($maintenancePath)) {
unlink($maintenancePath);
if (!unlink($maintenancePath)) {
throw new \RuntimeException(sprintf('Cannot remove maintenance file at "%s".', $maintenancePath));
}
}
$dto = new ManagedApplication();
$dto->slug = $appConfig['slug'];
$dto->name = $appConfig['name'];
$dto->maintenance = file_exists($maintenancePath);
return $dto;
return $data;
}
}

View File

@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ManagedApplication;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class ManagedApplicationProvider implements ProviderInterface
{
/**
* @param list<array{name: string, slug: string, maintenance_path: string}> $managedApplications
*/
public function __construct(
#[Autowire('%app.managed_applications%')]
private array $managedApplications,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ManagedApplication|array
{
if ($operation instanceof GetCollection) {
return array_map(
fn (array $app) => $this->buildDto($app),
$this->managedApplications,
);
}
$slug = $uriVariables['slug'] ?? '';
foreach ($this->managedApplications as $app) {
if ($app['slug'] === $slug) {
return $this->buildDto($app);
}
}
throw new NotFoundHttpException(sprintf('Application "%s" not found.', $slug));
}
/**
* @param array{name: string, slug: string, maintenance_path: string} $app
*/
private function buildDto(array $app): ManagedApplication
{
$dto = new ManagedApplication();
$dto->slug = $app['slug'];
$dto->name = $app['name'];
$dto->maintenance = file_exists($app['maintenance_path']);
return $dto;
}
}

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @implements ProviderInterface<User>
@@ -20,7 +21,12 @@ final readonly class MeProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): User
{
// @var User $user
return $this->security->getUser();
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('User not authenticated.');
}
return $user;
}
}

View File

@@ -0,0 +1,35 @@
<?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;
}
}