52 Commits

Author SHA1 Message Date
gitea-actions
8fb71e6370 chore: bump version to v0.1.22
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-07 10:16:54 +00:00
e128b45caa fix : affichage log docker et symfony
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-07 12:16:45 +02:00
gitea-actions
d331ef4577 chore: bump version to v0.1.21
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-07 10:01:09 +00:00
b769abdbe1 feat : ajout de la lecture des logs symfony et docker (#3)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #3
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-07 10:01:01 +00:00
gitea-actions
7e342c9aeb chore: bump version to v0.1.20
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 3m31s
2026-04-07 08:31:10 +00:00
419d3b24cb fix : ajout d'un préfix pour les path des app et correction de l'affichage
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-07 10:30:58 +02:00
gitea-actions
777224709d chore: bump version to v0.1.19
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-06 15:43:16 +00:00
0a4b0cdc14 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-04-06 17:43:10 +02:00
3fd745196f fix : mount /var/www as /mnt/apps, fix docker socket GID for www-data
- Mount host /var/www into /mnt/apps to avoid conflict with container /var/www/html
- Use GID 987 (host docker group) instead of 999 for socket access
- Add group_add in docker-compose for container-level GID

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:42:39 +02:00
8481fe8fef fix : mount /var/www as /mnt/apps to avoid conflict with /var/www/html
Host /var/www contains all apps. Mounting it at /mnt/apps avoids
overwriting the container's /var/www/html where Central lives.
App paths in the UI should use /mnt/apps/ prefix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:15:39 +02:00
061ab13d2b fix : mount individual app dirs instead of /var/www to avoid conflict with /var/www/html
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:13:56 +02:00
gitea-actions
656aaf816f chore: bump version to v0.1.18
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-06 15:06:40 +00:00
36777e50a8 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-06 17:06:33 +02:00
fdd1182caf fix : mount /var/www instead of individual app dirs
Avoids modifying docker-compose every time a new app is added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:06:14 +02:00
98237daa56 fix : move symfony/process from require-dev to require
Needed in prod for DockerService and DeployService.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:00:44 +02:00
gitea-actions
67b722612c chore: bump version to v0.1.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 17s
2026-04-06 14:57:59 +00:00
4e32fb8222 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-06 16:57:50 +02:00
f9555c515c fix : create var/log dir, add docker group for www-data, remove old maintenance dirs
- Create var/log in Dockerfile (fixes Symfony logging in prod)
- Add www-data to docker group (fixes docker CLI access for health/deploy)
- Remove obsolete /var/www/maintenance/* dirs (volumes mount app dirs directly)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:56:05 +02:00
gitea-actions
6458a1b35c chore: bump version to v0.1.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 17s
2026-04-06 14:50:18 +00:00
de81b55867 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-04-06 16:50:12 +02:00
a3cd1f6b74 fix : use custom EnvironmentCreateProcessor to properly add environments
API Platform's default sub-resource POST was replacing instead of adding.
Custom processor with read:false + Link + manual persist fixes this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:49:44 +02:00
gitea-actions
5eb4921c60 chore: bump version to v0.1.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 17s
2026-04-06 14:44:06 +00:00
c7e32c74b6 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-06 16:43:58 +02:00
e9850fdb2e fix : correct Link uriVariables for environment POST endpoint
Use toProperty + identifiers instead of fromProperty to resolve
application slug correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:43:26 +02:00
gitea-actions
18ff7b4dbb chore: bump version to v0.1.14
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 2m55s
2026-04-06 14:31:48 +00:00
e99c50d243 fix : regenerate package-lock.json with node:lts-alpine for CI compatibility
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-06 16:31:22 +02:00
gitea-actions
d13e789a63 chore: bump version to v0.1.13
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 10s
2026-04-06 14:29:11 +00:00
d03fc7d33c feat/ajout-de-fonctionnalites (#2)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #2
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-06 14:29:04 +00:00
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
gitea-actions
b39e6f81d8 chore: bump version to v0.1.3
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 46s
2026-04-03 10:46:11 +00:00
28690be509 revert(build) : retire le contournement ipv4 du dockerfile
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-03 12:46:00 +02:00
gitea-actions
7f6634bec7 chore: bump version to v0.1.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 55s
2026-04-03 10:08:44 +00:00
b0b05970c1 build(central) : force ipv4 pour composer et npm
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-03 12:08:33 +02:00
gitea-actions
57be0bbf85 chore: bump version to v0.1.1
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 1m33s
2026-04-03 09:53:09 +00:00
d85d1cc1d6 fix(frontend) : regenere le package-lock
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-04-03 11:52:57 +02:00
73 changed files with 7567 additions and 533 deletions

14
.env
View File

@@ -44,9 +44,11 @@ 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 ###
###> apps ###
APPS_BASE_PATH=/mnt/apps
###< apps ###
###> 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

@@ -26,6 +26,7 @@
"symfony/http-client": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "8.0.*",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",

132
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bfd26e903d79f710cfe95452c05f2a25",
"content-hash": "51813b5c3b6dacd3cc99cfe121ab918b",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -6234,6 +6234,71 @@
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"shasum": ""
},
"require": {
"php": ">=8.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/property-access",
"version": "v8.0.8",
@@ -11018,71 +11083,6 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"shasum": ""
},
"require": {
"php": ">=8.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "theseer/tokenizer",
"version": "2.0.1",

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.0'
app.version: '0.1.22'

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,8 @@ 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
- ${HOST_APPS_PATH}:/mnt/apps
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-center 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

@@ -0,0 +1,113 @@
<script setup lang="ts">
const { t } = useI18n()
const props = withDefaults(defineProps<{
modelValue: boolean
title: string
content: string
loading?: boolean
showLevelFilter?: boolean
}>(), {
loading: false,
showLevelFilter: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'refresh', lines: number, level: string): void
}>()
const selectedLines = ref(100)
const selectedLevel = ref('')
const lineOptions = [50, 100, 500, 1000]
const copied = ref(false)
function refresh() {
emit('refresh', selectedLines.value, selectedLevel.value)
}
async function copyLogs() {
if (!props.content) return
await navigator.clipboard.writeText(props.content)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
watch(() => props.modelValue, (open) => {
if (open) {
copied.value = false
refresh()
}
})
</script>
<template>
<AppModal
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event)"
max-width="2xl"
>
<template #title>{{ title }}</template>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<label class="text-xs text-neutral-400">{{ t('logs.lines') }}</label>
<select
v-model="selectedLines"
class="rounded-md border border-neutral-300 px-2 py-1 text-sm"
@change="refresh"
>
<option v-for="n in lineOptions" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div v-if="showLevelFilter" class="flex items-center gap-2">
<label class="text-xs text-neutral-400">{{ t('logs.level') }}</label>
<select
v-model="selectedLevel"
class="rounded-md border border-neutral-300 px-2 py-1 text-sm"
@change="refresh"
>
<option value="">{{ t('logs.levelAll') }}</option>
<option value="ERROR">ERROR</option>
<option value="WARNING">WARNING</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
</select>
</div>
<MalioButtonIcon
icon="mdi:refresh"
:aria-label="t('logs.refresh')"
icon-size="18"
@click="refresh"
/>
</div>
<MalioButtonIcon
:icon="copied ? 'mdi:check' : 'mdi:content-copy'"
:aria-label="t('logs.copy')"
icon-size="18"
:button-class="copied ? 'text-green-500' : ''"
@click="copyLogs"
/>
</div>
<pre
v-if="content"
class="max-h-96 overflow-auto rounded-lg bg-neutral-900 p-4 text-xs text-green-400 font-mono whitespace-pre-wrap"
>{{ content }}</pre>
<p v-else-if="!loading" class="text-center text-neutral-400 py-8">
{{ t('logs.noContent') }}
</p>
<template #footer>
<MalioButton
:label="t('applications.form.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
</template>
</AppModal>
</template>

View File

@@ -2,9 +2,7 @@
<NuxtLink
:to="to"
class="group/link relative flex items-center transition-colors hover:text-primary-500"
:class="linkClasses"
:active-class="exact ? '' : activeClass"
:exact-active-class="exact ? activeClass : ''"
:class="[linkClasses, isActive ? activeClass : '']"
>
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
<span
@@ -33,6 +31,15 @@ const props = defineProps<{
exact?: boolean
}>()
const route = useRoute()
const isActive = computed(() => {
if (props.exact) {
return route.path === props.to
}
return route.path === props.to || route.path.startsWith(props.to + '/')
})
const activeClass = computed(() => {
if (props.collapsed) {
return '!text-primary-500 bg-primary-500/10'

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,39 +32,126 @@
},
"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.",
"logout": "Déconnexion réussie."
}
},
"logs": {
"docker": "Logs Docker",
"symfony": "Logs Symfony",
"lines": "Lignes",
"level": "Niveau",
"levelAll": "Tous",
"refresh": "Actualiser",
"noContent": "Aucun log disponible",
"copy": "Copier les logs",
"title": "Logs"
},
"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",
"unavailable": "Docker indisponible"
}
},
"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",
"pathHint": "Prefixe automatique : /mnt/apps",
"pathHintLog": "Chemin dans le container, ex : var/log/prod.log",
"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

@@ -7,7 +7,7 @@
"name": "nuxt-app",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.2.0",
"@malio/layer-ui": "^1.2.2",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -72,7 +72,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -570,12 +569,36 @@
"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",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1019,6 +1042,7 @@
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -1028,6 +1052,7 @@
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
@@ -1042,6 +1067,7 @@
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1"
},
@@ -1054,6 +1080,7 @@
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -1066,6 +1093,7 @@
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
@@ -1075,6 +1103,7 @@
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
@@ -1088,6 +1117,7 @@
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -1097,6 +1127,7 @@
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -1110,6 +1141,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=12.22"
},
@@ -1123,6 +1155,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18"
},
@@ -1635,9 +1668,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.2.1",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.1/layer-ui-1.2.1.tgz",
"integrity": "sha512-kY6Jeg11wceSgeJ/OX0xsYMENfXogb+nGduP7yVmc6HHIwKDtpn7VLRcJPlhNBUsKAvcFNk6IU08o6izdTMEQg==",
"version": "1.2.2",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.2/layer-ui-1.2.2.tgz",
"integrity": "sha512-nV4FL19rYSiXqMDTUlAtp6AYdj7YiwpHbf7/usiOPj7llpjHIC3GmcOX0X7oQeOMTtSU1aKL8k8wn1bhptrHYg==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1929,7 +1962,6 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
"license": "MIT",
"peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
@@ -2032,7 +2064,6 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "^3.5.30",
"defu": "^6.1.4",
@@ -4258,7 +4289,8 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -4270,7 +4302,8 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/resolve": {
"version": "1.20.2",
@@ -4584,7 +4617,6 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.32",
@@ -4817,7 +4849,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4857,6 +4888,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5185,7 +5217,6 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -5383,7 +5414,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5485,7 +5515,6 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -5638,8 +5667,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/cliui": {
"version": "9.0.1",
@@ -6128,7 +6156,8 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -6532,6 +6561,7 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -6621,6 +6651,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
@@ -6651,6 +6682,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -6663,6 +6695,7 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -6672,6 +6705,7 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
@@ -6689,6 +6723,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -6714,6 +6749,7 @@
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -6726,6 +6762,7 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -6826,7 +6863,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/fast-fifo": {
"version": "1.3.2",
@@ -6866,13 +6904,15 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/fast-npm-meta": {
"version": "1.4.2",
@@ -6941,6 +6981,7 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -6971,6 +7012,7 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -6987,6 +7029,7 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -6999,7 +7042,8 @@
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/foreground-child": {
"version": "3.3.1",
@@ -7541,6 +7585,7 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.19"
}
@@ -7911,19 +7956,22 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json5": {
"version": "2.2.3",
@@ -8001,6 +8049,7 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -8261,6 +8310,7 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -8345,6 +8395,7 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -8721,7 +8772,8 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/negotiator": {
"version": "0.6.3",
@@ -9453,7 +9505,6 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dxup/nuxt": "^0.4.0",
"@nuxt/cli": "^3.34.0",
@@ -10505,6 +10556,7 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -10556,7 +10608,6 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.112.0"
},
@@ -10640,6 +10691,7 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -10655,6 +10707,7 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -10691,6 +10744,7 @@
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -10794,7 +10848,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -10863,7 +10916,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11407,7 +11459,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -11470,6 +11521,7 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -11506,6 +11558,7 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -11957,7 +12010,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12764,7 +12816,6 @@
"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",
@@ -13102,6 +13153,7 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -13586,6 +13638,7 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@@ -13610,7 +13663,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -14432,7 +14484,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32",
@@ -14469,7 +14520,6 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0",
@@ -14555,6 +14605,7 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14776,6 +14827,7 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},

View File

@@ -11,7 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@malio/layer-ui": "^1.2.0",
"@malio/layer-ui": "^1.2.2",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -0,0 +1,687 @@
<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'
import type { LogOutput } from '~/services/dto/logs'
import { getDockerLogs, getSymfonyLog } from '~/services/logs'
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)
// Log modals
const showLogModal = ref(false)
const logContent = ref('')
const logLoading = ref(false)
const logTitle = ref('')
const logEnvId = ref<number | null>(null)
const logFileId = ref<number | null>(null)
const logIsSymfony = 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'
}
}
// Log functions
async function openDockerLogs(env: Environment) {
logEnvId.value = env.id!
logFileId.value = null
logIsSymfony.value = false
logTitle.value = `${t('logs.docker')}${env.name}`
logContent.value = ''
showLogModal.value = true
}
async function openSymfonyLog(env: Environment, lf: { id?: number, label: string }) {
logEnvId.value = env.id!
logFileId.value = lf.id!
logIsSymfony.value = true
logTitle.value = `${t('logs.symfony')}${lf.label}`
logContent.value = ''
showLogModal.value = true
}
async function refreshLogs(lines: number, level: string) {
if (!logEnvId.value) return
logLoading.value = true
try {
let result: LogOutput
if (logIsSymfony.value && logFileId.value) {
result = await getSymfonyLog(logEnvId.value, logFileId.value, lines, level || undefined)
} else {
result = await getDockerLogs(logEnvId.value, lines)
}
logContent.value = result.content
} finally {
logLoading.value = false
}
}
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">
<div class="flex items-center">
<MalioButtonIcon
:aria-label="t('applications.detail.editButton')"
variant="filled"
icon="mdi:pencil"
@click="openEditAppModal"
/>
</div>
<div class="flex items-center">
<MalioButtonIcon
:aria-label="t('applications.detail.editButton')"
variant="filled"
icon="mdi:trash-can-outline"
button-class="bg-m-btn-danger hover:bg-m-btn-danger-hover active:bg-m-btn-danger-active text-white cursor-pointer"
@click="handleDeleteApp"
/>
</div>
</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="font-bold">{{ 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="font-bold">{{ 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('logs.docker')"
variant="secondary"
icon-name="mdi:text-box-outline"
icon-position="left"
@click="openDockerLogs(env)"
/>
<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-sm font-bold uppercase tracking-wider 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 items-center">
<span class="font-medium">{{ lf.label }}</span>
<span class="font-mono text-neutral-400">{{ lf.path }}</span>
<MalioButtonIcon
icon="mdi:console"
:aria-label="lf.label"
variant="ghost"
icon-size="16"
@click="openSymfonyLog(env, lf)"
/>
</div>
</div>
<!-- Health metrics -->
<div v-if="healthByEnvId[env.id!]" class="mt-4 border-t border-neutral-200 py-3">
<p class="text-sm font-bold uppercase tracking-wider mb-2">{{ t('environments.health.title') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-5 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>
<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')"
hint="Ex : gitea.malio.fr/malio-dev/sirh"
required
/>
<MalioInputText
v-model="editForm.giteaUrl"
:label="t('applications.form.giteaUrl')"
hint="Ex : https://gitea.malio.fr/malio-dev/sirh"
/>
</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')"
groupClass="mt-0"
required
/>
<MalioInputText
v-model="envForm.containerName"
:label="t('environments.form.containerName')"
groupClass="mt-0"
required
/>
<MalioInputText
v-model="envForm.deployScriptPath"
:label="t('environments.form.deployScriptPath')"
:hint="t('environments.form.pathHint')"
required
/>
<MalioInputText
v-model="envForm.maintenanceFilePath"
:label="t('environments.form.maintenanceFilePath')"
:hint="t('environments.form.pathHint')"
required
/>
<MalioInputText
v-model="envForm.appUrl"
:label="t('environments.form.appUrl')"
groupClass="mt-0"
/>
</div>
<!-- Log files -->
<div>
<div class="flex items-center justify-between mb-4">
<p class="text-md font-bold">{{ 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">
<div class="w-1/3">
<MalioInputText v-model="lf.label" :label="t('environments.logFiles.label')" groupClass="mt-0" inputClass="flex-1" required />
</div>
<div class="w-2/3">
<MalioInputText v-model="lf.path" :label="t('environments.logFiles.path')" groupClass="mt-0" inputClass="flex-[2]" :hint="`${t('environments.form.pathHint')}/${application?.slug ?? ''}`" required />
</div>
<div class="h-[46px] flex items-center">
<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>
</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>
<!-- Log modal -->
<LogModal
v-model="showLogModal"
:title="logTitle"
:content="logContent"
:loading="logLoading"
:show-level-filter="logIsSymfony"
@refresh="refreshLogs"
/>
</div>
</template>

View File

@@ -0,0 +1,153 @@
<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')"
hint="Ex : gitea.malio.fr/malio-dev/sirh"
required
/>
<MalioInputText
v-model="createForm.giteaUrl"
:label="t('applications.form.giteaUrl')"
hint="Ex : https://gitea.malio.fr/malio-dev/sirh"
/>
</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

@@ -0,0 +1,5 @@
type LogOutput = {
content: string
lines: number
source: 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,
})
}

15
frontend/services/logs.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { LogOutput } from './dto/logs'
export function getDockerLogs(envId: number, lines: number = 100): Promise<LogOutput> {
return useApi().get<LogOutput>(`/environments/${envId}/logs/docker`, { lines }, {
toast: false,
})
}
export function getSymfonyLog(envId: number, logFileId: number, lines: number = 100, level?: string): Promise<LogOutput> {
const query: Record<string, any> = { lines }
if (level) query.level = level
return useApi().get<LogOutput>(`/environments/${envId}/logs/symfony/${logFileId}`, query, {
toast: false,
})
}

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

@@ -7,3 +7,4 @@ POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5436
XDEBUG_CLIENT_HOST=host.docker.internal
HOST_APPS_PATH=/home/user/workspace

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/*
@@ -71,10 +71,13 @@ COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \
# Permissions + directories
RUN mkdir -p /var/www/html/var/log /var/www/html/var/uploads \
&& chown -R www-data:www-data /var/www/html/var
# Allow www-data to use Docker socket (GID 987 matches host's docker group)
RUN groupadd -g 987 dockerhost 2>/dev/null; usermod -aG dockerhost www-data
WORKDIR /var/www/html
EXPOSE 80

View File

@@ -1,17 +1,17 @@
services:
app:
image: gitea.malio.fr/malio-dev/central:${CENTRAL_IMAGE_TAG:-latest}
container_name: central-app
env_file: .env
ports:
- "8084:80"
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
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
app:
image: gitea.malio.fr/malio-dev/central:${CENTRAL_IMAGE_TAG:-latest}
container_name: central-app
env_file: .env
ports:
- "8084:80"
group_add:
- "987"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
- /var/run/docker.sock:/var/run/docker.sock
- /var/www:/mnt/apps
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

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\DockerLogProvider;
use App\State\SymfonyLogProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/environments/{id}/logs/docker',
security: "is_granted('ROLE_ADMIN')",
provider: DockerLogProvider::class,
),
new Get(
uriTemplate: '/environments/{id}/logs/symfony/{logFileId}',
security: "is_granted('ROLE_ADMIN')",
provider: SymfonyLogProvider::class,
),
],
)]
final class LogOutput
{
public string $content = '';
public int $lines = 0;
public string $source = '';
}

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('php-sirh-fpm');
$sirhProd->setDeployScriptPath('/SIRH/deploy/docker/deploy.sh');
$sirhProd->setMaintenanceFilePath('/SIRH/deploy/docker/maintenance.on');
$sirhProd->setAppUrl('http://sirh.malio-dev.fr');
$sirh->addEnvironment($sirhProd);
$sirhProdLog = new LogFile();
$sirhProdLog->setLabel('dev');
$sirhProdLog->setPath('/SIRH/var/log/dev.log');
$sirhProd->addLogFile($sirhProdLog);
$sirhCronLog = new LogFile();
$sirhCronLog->setLabel('cron');
$sirhCronLog->setPath('/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('/lesstime/deploy/docker/deploy.sh');
$lesstimeProd->setMaintenanceFilePath('/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('/inventory/deploy/docker/deploy.sh');
$inventoryProd->setMaintenanceFilePath('/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('/inventory/deploy/docker/deploy-test.sh');
$inventoryRecette->setMaintenanceFilePath('/inventory/deploy/docker/maintenance-test.on');
$inventoryRecette->setAppUrl('http://inventory-test.malio-dev.fr');
$inventory->addEnvironment($inventoryRecette);
$manager->persist($inventory);
}
}

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

@@ -0,0 +1,192 @@
<?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 App\State\ApplicationProvider;
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')",
provider: ApplicationProvider::class,
),
new Get(
uriVariables: ['slug'],
normalizationContext: ['groups' => ['app:read', 'app:detail']],
security: "is_granted('ROLE_ADMIN')",
provider: ApplicationProvider::class,
),
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;
}
}

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

@@ -0,0 +1,208 @@
<?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\EnvironmentCreateProcessor;
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(toProperty: 'application', fromClass: Application::class, identifiers: ['slug']),
],
read: false,
security: "is_granted('ROLE_ADMIN')",
processor: EnvironmentCreateProcessor::class,
),
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 $this->maintenance ?? false;
}
public function setMaintenance(bool $maintenance): static
{
$this->maintenance = $maintenance;
return $this;
}
}

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,20 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class AppPathResolver
{
public function __construct(
#[Autowire('%env(APPS_BASE_PATH)%')]
private string $basePath,
) {}
public function resolve(string $relativePath): string
{
return rtrim($this->basePath, '/') . '/' . ltrim($relativePath, '/');
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Environment;
use Symfony\Component\Process\Process;
final readonly class DeployService
{
public function __construct(
private AppPathResolver $pathResolver,
) {}
/**
* @return array{success: bool, output: string, exitCode: int}
*/
public function deploy(Environment $environment, string $tag): array
{
$relativePath = $environment->getDeployScriptPath();
if (null === $relativePath) {
return [
'success' => false,
'output' => 'Deploy script path is not configured.',
'exitCode' => 1,
];
}
$scriptPath = $this->pathResolver->resolve($relativePath);
if (!file_exists($scriptPath)) {
return [
'success' => false,
'output' => sprintf('Deploy script not found: %s', $scriptPath),
'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,132 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Process\Process;
final class DockerService
{
private ?bool $dockerAvailable = null;
private function isDockerAvailable(): bool
{
if (null === $this->dockerAvailable) {
$process = new Process(['which', 'docker']);
$process->setTimeout(5);
$process->run();
$this->dockerAvailable = $process->isSuccessful();
}
return $this->dockerAvailable;
}
/**
* @return array{status: string, image: string, version: string, startedAt: string}
*/
public function getContainerStatus(string $containerName): array
{
if (!$this->isDockerAvailable()) {
return [
'status' => 'unavailable',
'image' => '',
'version' => '',
'startedAt' => '',
];
}
$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
{
if (!$this->isDockerAvailable()) {
return [
'cpuPercent' => 0.0,
'memoryUsage' => '',
'memoryLimit' => '',
'memoryPercent' => 0.0,
];
}
$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.');
}
}

179
src/Service/LogService.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Process\Process;
final readonly class LogService
{
public function __construct(
private AppPathResolver $pathResolver,
) {}
public function getDockerLogs(string $containerName, int $lines = 100, ?string $since = null): string
{
$check = new Process(['which', 'docker']);
$check->setTimeout(5);
$check->run();
if (!$check->isSuccessful()) {
return 'Docker CLI is not available in this environment.';
}
$args = ['docker', 'logs', '--tail', (string) $lines];
if (null !== $since) {
$args[] = '--since';
$args[] = $since;
}
$args[] = $containerName;
$process = new Process($args);
$process->setTimeout(10);
$process->run();
$output = $process->getOutput() . $process->getErrorOutput();
return $this->formatDockerOutput($output);
}
private function formatDockerOutput(string $output): string
{
$rawLines = explode("\n", trim($output));
$formatted = [];
foreach ($rawLines as $line) {
if ('' === $line) {
continue;
}
// Try parsing as Symfony monolog JSON format
$parsed = $this->parseSymfonyLogLine($line);
if (null !== $parsed) {
if ('doctrine' === $parsed['channel']) {
continue;
}
$formatted[] = sprintf('[%s] %s.%s: %s', $parsed['date'], $parsed['channel'], $parsed['level'], $parsed['message']);
continue;
}
// Try parsing as JSON log ({"message":"...","level":...})
$json = json_decode($line, true);
if (\is_array($json) && isset($json['message'])) {
$date = isset($json['datetime']) ? substr($json['datetime'], 0, 19) : '';
$date = str_replace('T', ' ', $date);
$channel = $json['channel'] ?? 'app';
$level = $json['level_name'] ?? 'INFO';
if ('doctrine' === $channel) {
continue;
}
$formatted[] = sprintf('[%s] %s.%s: %s', $date, $channel, $level, $json['message']);
continue;
}
// Keep raw lines that don't match any format (nginx access logs, etc.)
$formatted[] = $line;
}
return implode("\n", $formatted);
}
public function getSymfonyLog(string $containerName, string $logPath, int $lines = 100, ?string $level = null): string
{
$check = new Process(['which', 'docker']);
$check->setTimeout(5);
$check->run();
if (!$check->isSuccessful()) {
// Fallback: try reading from filesystem (dev mode)
$localPath = $this->pathResolver->resolve($logPath);
if (!file_exists($localPath)) {
return sprintf('Log file not found: %s (Docker CLI unavailable, local path: %s)', $logPath, $localPath);
}
$readLines = (null !== $level && '' !== $level) ? $lines * 5 : $lines;
$process = new Process(['tail', '-n', (string) $readLines, $localPath]);
$process->setTimeout(10);
$process->run();
return $this->formatSymfonyOutput($process->getOutput(), $lines, $level);
}
// Read more lines than requested to compensate for filtering
$readLines = (null !== $level && '' !== $level) ? $lines * 5 : $lines;
$process = new Process(['docker', 'exec', $containerName, 'tail', '-n', (string) $readLines, $logPath]);
$process->setTimeout(10);
$process->run();
if (!$process->isSuccessful()) {
return sprintf('Error reading log: %s', trim($process->getErrorOutput()));
}
return $this->formatSymfonyOutput($process->getOutput(), $lines, $level);
}
private function formatSymfonyOutput(string $output, int $lines, ?string $level): string
{
$rawLines = explode("\n", trim($output));
$formatted = [];
foreach ($rawLines as $line) {
if ('' === $line) {
continue;
}
$parsed = $this->parseSymfonyLogLine($line);
if (null === $parsed) {
continue;
}
// Skip noisy channels
if ('doctrine' === $parsed['channel']) {
continue;
}
if (null !== $level && '' !== $level && !str_contains(strtoupper($parsed['level']), strtoupper($level))) {
continue;
}
$formatted[] = sprintf('[%s] %s.%s: %s', $parsed['date'], $parsed['channel'], $parsed['level'], $parsed['message']);
}
// Keep only the last N lines after filtering
$formatted = \array_slice($formatted, -$lines);
return implode("\n", $formatted);
}
/**
* @return array{date: string, level: string, channel: string, message: string}|null
*/
private function parseSymfonyLogLine(string $line): ?array
{
// Standard Symfony monolog format: [2026-04-03T15:33:19.304937+02:00] channel.LEVEL: message {context} []
if (preg_match('/^\[([^\]]+)\]\s+(\w+)\.(\w+):\s+(.+)$/', $line, $matches)) {
$date = substr($matches[1], 0, 19); // Trim to YYYY-MM-DDTHH:MM:SS
$message = $matches[4];
// Remove JSON context at the end: {"key":"value"} []
$message = preg_replace('/\s*\{.*\}\s*\[\]\s*$/', '', $message) ?? $message;
// Remove trailing []
$message = preg_replace('/\s*\[\]\s*$/', '', $message) ?? $message;
return [
'date' => str_replace('T', ' ', $date),
'level' => $matches[3],
'channel' => $matches[2],
'message' => $message,
];
}
return null;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Application;
use App\Repository\ApplicationRepository;
use App\Service\AppPathResolver;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class ApplicationProvider implements ProviderInterface
{
public function __construct(
private ApplicationRepository $applicationRepository,
private AppPathResolver $pathResolver,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Application|array
{
if ($operation instanceof GetCollection) {
$apps = $this->applicationRepository->findAll();
foreach ($apps as $app) {
$this->resolveMaintenanceStatus($app);
}
return $apps;
}
$slug = $uriVariables['slug'] ?? '';
$app = $this->applicationRepository->findOneBy(['slug' => $slug]);
if (null === $app) {
throw new NotFoundHttpException(sprintf('Application "%s" not found.', $slug));
}
$this->resolveMaintenanceStatus($app);
return $app;
}
private function resolveMaintenanceStatus(Application $app): void
{
foreach ($app->getEnvironments() as $env) {
$path = $env->getMaintenanceFilePath();
if (null !== $path) {
$env->setMaintenance(file_exists($this->pathResolver->resolve($path)));
}
}
}
}

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,49 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\LogOutput;
use App\Repository\EnvironmentRepository;
use App\Service\LogService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class DockerLogProvider implements ProviderInterface
{
public function __construct(
private EnvironmentRepository $environmentRepository,
private LogService $logService,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): LogOutput
{
$id = $uriVariables['id'] ?? null;
$environment = $id ? $this->environmentRepository->find($id) : null;
if (null === $environment) {
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
}
$request = $this->requestStack->getCurrentRequest();
$lines = (int) ($request?->query->get('lines', '100') ?? 100);
$since = $request?->query->get('since');
$content = $this->logService->getDockerLogs(
$environment->getContainerName(),
$lines,
$since,
);
$dto = new LogOutput();
$dto->content = $content;
$dto->lines = $lines;
$dto->source = sprintf('docker:%s', $environment->getContainerName());
return $dto;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Environment;
use App\Repository\ApplicationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class EnvironmentCreateProcessor implements ProcessorInterface
{
public function __construct(
private ApplicationRepository $applicationRepository,
private EntityManagerInterface $entityManager,
) {}
/**
* @param Environment $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Environment
{
$slug = $uriVariables['slug']
?? $context['request']?->attributes->get('slug')
?? $context['request']?->attributes->get('_route_params')['slug']
?? '';
$application = $this->applicationRepository->findOneBy(['slug' => $slug]);
if (null === $application) {
throw new NotFoundHttpException(sprintf('Application "%s" not found.', $slug));
}
$data->setApplication($application);
$this->entityManager->persist($data);
$this->entityManager->flush();
return $data;
}
}

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,47 @@ 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 App\Service\AppPathResolver;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class MaintenanceToggleProcessor implements ProcessorInterface
{
/**
* @param list<array{name: string, slug: string, maintenance_path: string}> $managedApplications
*/
public function __construct(
#[Autowire('%app.managed_applications%')]
private array $managedApplications,
private AppPathResolver $pathResolver,
) {}
/**
* @param ManagedApplication $data
* @param Environment $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;
$relativePath = $data->getMaintenanceFilePath();
foreach ($this->managedApplications as $app) {
if ($app['slug'] === $slug) {
$appConfig = $app;
break;
}
if (null === $relativePath) {
throw new BadRequestHttpException('Maintenance file path is not configured for this environment.');
}
if (null === $appConfig) {
throw new NotFoundHttpException(sprintf('Application "%s" not found.', $slug));
}
$maintenancePath = $this->pathResolver->resolve($relativePath);
$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,58 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\LogOutput;
use App\Repository\EnvironmentRepository;
use App\Repository\LogFileRepository;
use App\Service\LogService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class SymfonyLogProvider implements ProviderInterface
{
public function __construct(
private EnvironmentRepository $environmentRepository,
private LogFileRepository $logFileRepository,
private LogService $logService,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): LogOutput
{
$envId = $uriVariables['id'] ?? null;
$logFileId = $uriVariables['logFileId'] ?? null;
$environment = $envId ? $this->environmentRepository->find($envId) : null;
if (null === $environment) {
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $envId));
}
$logFile = $logFileId ? $this->logFileRepository->find($logFileId) : null;
if (null === $logFile || $logFile->getEnvironment()?->getId() !== $environment->getId()) {
throw new NotFoundHttpException(sprintf('Log file "%s" not found.', $logFileId));
}
$request = $this->requestStack->getCurrentRequest();
$lines = (int) ($request?->query->get('lines', '100') ?? 100);
$level = $request?->query->get('level');
$content = $this->logService->getSymfonyLog(
$environment->getContainerName(),
$logFile->getPath(),
$lines,
$level,
);
$dto = new LogOutput();
$dto->content = $content;
$dto->lines = $lines;
$dto->source = sprintf('symfony:%s', $logFile->getLabel());
return $dto;
}
}

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;
}
}