45 Commits

Author SHA1 Message Date
gitea-actions
2145d2cf9b chore: bump version to v0.1.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-04-08 14:21:24 +00:00
Matthieu
ce744b3aba fix : connect to target database for table count and largest table queries
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
information_schema and pg_class only show tables from the current database.
Open a temporary connection to the target database for these queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:21:15 +02:00
gitea-actions
838378a409 chore: bump version to v0.1.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-04-08 14:11:09 +00:00
Matthieu
95c90a258f feat(frontend) : add database info section and form field
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:11:00 +02:00
Matthieu
20d6dcea45 feat(i18n) : add database section translations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:11:00 +02:00
Matthieu
1cb2ff2130 feat(frontend) : add DatabaseInfo type and service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:11:00 +02:00
Matthieu
df755d521c feat : add DatabaseInfo API resource and provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:11:00 +02:00
Matthieu
0019b5987d feat : add DatabaseService for PostgreSQL metrics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:11:00 +02:00
Matthieu
41d6405872 feat(entity) : add databaseName field to Environment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:11:00 +02:00
gitea-actions
e0ab5b5961 chore: bump version to v0.1.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-08 09:29:45 +00:00
Matthieu
560734d72c Revert "fix : resolve Docker port conflicts and fix var/ permissions on install"
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
This reverts commit 123d9b306f.
2026-04-08 11:29:38 +02:00
gitea-actions
18589823f3 chore: bump version to v0.1.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-08 09:28:03 +00:00
Matthieu
ab2b3fd9ef feat : display container port mappings in environment health
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Extract exposed ports from docker inspect and show them as badges (hostPort:containerPort)
in the environment health section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:27:46 +02:00
Matthieu
123d9b306f fix : resolve Docker port conflicts and fix var/ permissions on install
Port PG 5436→5437, port frontend 3003→3005 to avoid conflicts with Coltura.
Add fix-permissions target in Makefile to create var/cache and var/log as root before composer install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:27:37 +02:00
gitea-actions
ca3445103d chore: bump version to v0.1.25
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 3m11s
2026-04-08 07:23:47 +00:00
18f3de1ba9 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-08 09:23:39 +02:00
52571c651f fix : install docker-compose plugin from GitHub instead of apt
docker-compose-plugin package is not in Debian default repos.
Download the binary directly from GitHub releases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:18:44 +02:00
gitea-actions
b9712643de chore: bump version to v0.1.24
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 12s
2026-04-08 07:11:01 +00:00
e954402959 fix : install docker-compose-plugin in prod Dockerfile
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Deploy scripts use `docker compose` (Compose V2 plugin) which is not
included in docker.io package.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:10:33 +02:00
gitea-actions
98d9032068 chore: bump version to v0.1.23
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 53s
2026-04-07 12:38:52 +00:00
5f6277d412 feat : update Malio UI + CLAUDE.md
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-07 14:38:42 +02:00
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
42 changed files with 1342 additions and 173 deletions

4
.env
View File

@@ -44,6 +44,10 @@ DEFAULT_URI=http://localhost
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> apps ###
APPS_BASE_PATH=/mnt/apps
###< apps ###
###> gitea ### ###> gitea ###
GITEA_API_URL=https://gitea.malio.fr GITEA_API_URL=https://gitea.malio.fr
GITEA_API_TOKEN=change_me_in_env_local GITEA_API_TOKEN=change_me_in_env_local

View File

@@ -31,6 +31,10 @@ frontend/services/dto/ # Types TypeScript
frontend/i18n/locales/ # Fichiers de traduction frontend/i18n/locales/ # Fichiers de traduction
``` ```
## Composants UI
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
## Commandes ## Commandes
```bash ```bash

View File

@@ -26,6 +26,7 @@
"symfony/http-client": "8.0.*", "symfony/http-client": "8.0.*",
"symfony/mime": "8.0.*", "symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0", "symfony/monolog-bundle": "^4.0",
"symfony/process": "8.0.*",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/rate-limiter": "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "bfd26e903d79f710cfe95452c05f2a25", "content-hash": "51813b5c3b6dacd3cc99cfe121ab918b",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -6234,6 +6234,71 @@
], ],
"time": "2024-09-09T11:45:10+00:00" "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", "name": "symfony/property-access",
"version": "v8.0.8", "version": "v8.0.8",
@@ -11018,71 +11083,6 @@
], ],
"time": "2024-10-20T05:08:20+00:00" "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", "name": "theseer/tokenizer",
"version": "2.0.1", "version": "2.0.1",

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.14' app.version: '0.1.29'

View File

@@ -26,6 +26,7 @@ services:
- ./LOG:/var/www/html/LOG - ./LOG:/var/www/html/LOG
- uploads_data:/var/www/html/var/uploads - uploads_data:/var/www/html/var/uploads
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ${HOST_APPS_PATH}:/mnt/apps
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
depends_on: depends_on:

View File

@@ -37,7 +37,7 @@
<!-- Footer --> <!-- Footer -->
<div class="border-t border-neutral-100 px-4 py-4 sm:px-8"> <div class="border-t border-neutral-100 px-4 py-4 sm:px-8">
<div class="flex justify-end gap-3"> <div class="flex justify-center gap-3">
<slot name="footer"> <slot name="footer">
<MalioButton <MalioButton
:label="cancelLabel" :label="cancelLabel"

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

View File

@@ -48,6 +48,17 @@
"logout": "Dé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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"description": "Vue d'ensemble du SI", "description": "Vue d'ensemble du SI",
@@ -56,7 +67,8 @@
"running": "En ligne", "running": "En ligne",
"exited": "Arrete", "exited": "Arrete",
"restarting": "Redemarrage", "restarting": "Redemarrage",
"not_found": "Introuvable" "not_found": "Introuvable",
"unavailable": "Docker indisponible"
} }
}, },
"applications": { "applications": {
@@ -106,6 +118,8 @@
"containerName": "Nom du container", "containerName": "Nom du container",
"deployScriptPath": "Chemin du script de deploiement", "deployScriptPath": "Chemin du script de deploiement",
"maintenanceFilePath": "Chemin du fichier de maintenance", "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", "appUrl": "URL de l'application",
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler" "cancel": "Annuler"
@@ -124,8 +138,23 @@
"uptime": "Uptime", "uptime": "Uptime",
"cpu": "CPU", "cpu": "CPU",
"memory": "Memoire", "memory": "Memoire",
"ports": "Ports",
"noData": "Aucune donnee disponible" "noData": "Aucune donnee disponible"
}, },
"database": {
"title": "Base de donnees",
"status": "Statut",
"connected": "Connecte",
"unreachable": "Injoignable",
"name": "Base",
"size": "Taille",
"tableCount": "Tables",
"activeConnections": "Connexions",
"cacheHitRatio": "Cache hit",
"largestTable": "Plus grosse table",
"formLabel": "Nom de la base",
"formHint": "Ex : coltura"
},
"deploy": { "deploy": {
"button": "Deployer", "button": "Deployer",
"title": "Deployer une version", "title": "Deployer une version",

View File

@@ -7,7 +7,7 @@
"name": "nuxt-app", "name": "nuxt-app",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.2.0", "@malio/layer-ui": "^1.2.3",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1668,9 +1668,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.2.1", "version": "1.2.3",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.1/layer-ui-1.2.1.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.3/layer-ui-1.2.3.tgz",
"integrity": "sha512-kY6Jeg11wceSgeJ/OX0xsYMENfXogb+nGduP7yVmc6HHIwKDtpn7VLRcJPlhNBUsKAvcFNk6IU08o6izdTMEQg==", "integrity": "sha512-5nRnBzRkXfs3PfKwKl6sH2ikrmSK7lTifcd0TX1QZP3rFRVRTgcT6mrsrpsbR9PwI27OeCNm0X6d0Ii92Rq7Yg==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -4,8 +4,10 @@ import { getApplication, updateApplication, deleteApplication } from '~/services
import { createEnvironment, updateEnvironment, deleteEnvironment, toggleMaintenance } from '~/services/environments' import { createEnvironment, updateEnvironment, deleteEnvironment, toggleMaintenance } from '~/services/environments'
import type { DeployResult } from '~/services/dto/deploy' import type { DeployResult } from '~/services/dto/deploy'
import { getAvailableTags, deploy } from '~/services/deploy' import { getAvailableTags, deploy } from '~/services/deploy'
import type { EnvironmentHealth } from '~/services/dto/dashboard' import type { EnvironmentHealth, DatabaseInfo } from '~/services/dto/dashboard'
import { getEnvironmentHealth } from '~/services/dashboard' import { getEnvironmentHealth, getDatabaseInfo } from '~/services/dashboard'
import type { LogOutput } from '~/services/dto/logs'
import { getDockerLogs, getSymfonyLog } from '~/services/logs'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -29,6 +31,18 @@ const deployResult = ref<DeployResult | null>(null)
const healthByEnvId = ref<Record<number, EnvironmentHealth>>({}) const healthByEnvId = ref<Record<number, EnvironmentHealth>>({})
const loadingHealth = ref(false) const loadingHealth = ref(false)
// Database info per env
const dbInfoByEnvId = ref<Record<number, DatabaseInfo>>({})
// 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 // App edit modal
const showAppModal = ref(false) const showAppModal = ref(false)
const editForm = ref<ApplicationWrite>({ name: '', slug: '', registryImage: '', description: '', giteaUrl: '' }) const editForm = ref<ApplicationWrite>({ name: '', slug: '', registryImage: '', description: '', giteaUrl: '' })
@@ -43,6 +57,7 @@ const envForm = ref<EnvironmentWrite>({
deployScriptPath: '', deployScriptPath: '',
maintenanceFilePath: '', maintenanceFilePath: '',
appUrl: '', appUrl: '',
databaseName: '',
logFiles: [], logFiles: [],
}) })
const isSubmittingEnv = ref(false) const isSubmittingEnv = ref(false)
@@ -55,6 +70,7 @@ async function loadApplication() {
loading.value = false loading.value = false
} }
loadHealthData() loadHealthData()
loadDatabaseData()
} }
// Application edit // Application edit
@@ -92,7 +108,7 @@ async function handleDeleteApp() {
// Environment CRUD // Environment CRUD
function openCreateEnvModal() { function openCreateEnvModal() {
editingEnvId.value = null editingEnvId.value = null
envForm.value = { name: '', containerName: '', deployScriptPath: '', maintenanceFilePath: '', appUrl: '', logFiles: [] } envForm.value = { name: '', containerName: '', deployScriptPath: '', maintenanceFilePath: '', appUrl: '', databaseName: '', logFiles: [] }
showEnvModal.value = true showEnvModal.value = true
} }
@@ -104,6 +120,7 @@ function openEditEnvModal(env: Environment) {
deployScriptPath: env.deployScriptPath, deployScriptPath: env.deployScriptPath,
maintenanceFilePath: env.maintenanceFilePath, maintenanceFilePath: env.maintenanceFilePath,
appUrl: env.appUrl ?? '', appUrl: env.appUrl ?? '',
databaseName: env.databaseName ?? '',
logFiles: env.logFiles.map(lf => ({ label: lf.label, path: lf.path })), logFiles: env.logFiles.map(lf => ({ label: lf.label, path: lf.path })),
} }
showEnvModal.value = true showEnvModal.value = true
@@ -204,6 +221,20 @@ async function loadHealthData() {
} }
} }
async function loadDatabaseData() {
if (!application.value?.environments?.length) return
const promises = application.value.environments.map(async (env) => {
if (!env.databaseName) return
try {
const info = await getDatabaseInfo(env.id!)
dbInfoByEnvId.value[env.id!] = info
} catch {
// silently ignore — no database configured or unreachable
}
})
await Promise.all(promises)
}
function formatUptime(startedAt: string): string { function formatUptime(startedAt: string): string {
if (!startedAt) return '-' if (!startedAt) return '-'
const start = new Date(startedAt) const start = new Date(startedAt)
@@ -226,6 +257,41 @@ function statusClass(status: string): string {
} }
} }
// 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(() => const envModalTitle = computed(() =>
editingEnvId.value ? t('environments.editButton') : t('environments.addButton') editingEnvId.value ? t('environments.editButton') : t('environments.addButton')
) )
@@ -256,20 +322,23 @@ onMounted(loadApplication)
<p v-if="application.description" class="text-neutral-500 mt-2">{{ application.description }}</p> <p v-if="application.description" class="text-neutral-500 mt-2">{{ application.description }}</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<MalioButton <div class="flex items-center">
:label="t('applications.detail.editButton')" <MalioButtonIcon
variant="secondary" :aria-label="t('applications.detail.editButton')"
icon-name="mdi:pencil" variant="filled"
icon-position="left" icon="mdi:pencil"
@click="openEditAppModal" @click="openEditAppModal"
/> />
<MalioButton </div>
:label="t('applications.detail.deleteButton')" <div class="flex items-center">
variant="danger" <MalioButtonIcon
icon-name="mdi:trash-can-outline" :aria-label="t('applications.detail.editButton')"
icon-position="left" variant="filled"
@click="handleDeleteApp" 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>
</div> </div>
@@ -277,11 +346,11 @@ onMounted(loadApplication)
<div class="rounded-lg bg-tertiary-500 p-5 mb-8"> <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 class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div> <div>
<span class="text-neutral-400">{{ t('applications.detail.registryImage') }} :</span> <span class="font-bold">{{ t('applications.detail.registryImage') }} :</span>
<span class="text-neutral-800 ml-1 font-mono">{{ application.registryImage }}</span> <span class="text-neutral-800 ml-1 font-mono">{{ application.registryImage }}</span>
</div> </div>
<div v-if="application.giteaUrl"> <div v-if="application.giteaUrl">
<span class="text-neutral-400">{{ t('applications.detail.giteaUrl') }} :</span> <span class="font-bold">{{ t('applications.detail.giteaUrl') }} :</span>
<a :href="application.giteaUrl" target="_blank" class="text-primary-500 hover:underline ml-1"> <a :href="application.giteaUrl" target="_blank" class="text-primary-500 hover:underline ml-1">
{{ application.giteaUrl }} {{ application.giteaUrl }}
</a> </a>
@@ -331,6 +400,13 @@ onMounted(loadApplication)
</a> </a>
</div> </div>
<div class="flex gap-2"> <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 <MalioButton
:label="t('environments.deploy.button')" :label="t('environments.deploy.button')"
icon-name="mdi:rocket-launch-outline" icon-name="mdi:rocket-launch-outline"
@@ -354,17 +430,24 @@ onMounted(loadApplication)
<!-- Log files --> <!-- Log files -->
<div v-if="env.logFiles.length" class="mt-4 border-t border-neutral-200 pt-3"> <div v-if="env.logFiles.length" class="mt-4 border-t border-neutral-200 pt-3">
<p class="text-xs font-semibold uppercase tracking-wider text-neutral-400 mb-2">{{ t('environments.logFiles.title') }}</p> <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"> <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-medium">{{ lf.label }}</span>
<span class="font-mono text-neutral-400">{{ lf.path }}</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>
</div> </div>
<!-- Health metrics --> <!-- Health metrics -->
<div v-if="healthByEnvId[env.id!]" class="mt-4 border-t border-neutral-200 pt-3"> <div v-if="healthByEnvId[env.id!]" class="mt-4 border-t border-neutral-200 py-3">
<p class="text-xs font-semibold uppercase tracking-wider text-neutral-400 mb-3">{{ t('environments.health.title') }}</p> <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-4 gap-3"> <div class="grid grid-cols-2 sm:grid-cols-6 gap-3">
<div> <div>
<p class="text-xs text-neutral-400">{{ t('environments.health.status') }}</p> <p class="text-xs text-neutral-400">{{ t('environments.health.status') }}</p>
<span <span
@@ -386,15 +469,72 @@ onMounted(loadApplication)
<p class="text-xs text-neutral-400">{{ t('environments.health.cpu') }}</p> <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> <p class="text-sm text-neutral-800 mt-1">{{ healthByEnvId[env.id!].cpuPercent }}%</p>
</div> </div>
<div class="col-span-2"> <div>
<p class="text-xs text-neutral-400">{{ t('environments.health.memory') }}</p> <p class="text-xs text-neutral-400">{{ t('environments.health.memory') }}</p>
<p class="text-sm text-neutral-800 mt-1"> <p class="text-sm text-neutral-800 mt-1">
{{ healthByEnvId[env.id!].memoryUsage }} / {{ healthByEnvId[env.id!].memoryLimit }} {{ healthByEnvId[env.id!].memoryUsage }} / {{ healthByEnvId[env.id!].memoryLimit }}
<span class="text-neutral-400">({{ healthByEnvId[env.id!].memoryPercent }}%)</span> <span class="text-neutral-400">({{ healthByEnvId[env.id!].memoryPercent }}%)</span>
</p> </p>
</div> </div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.health.ports') }}</p>
<div v-if="healthByEnvId[env.id!].ports?.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="(p, i) in healthByEnvId[env.id!].ports"
:key="i"
class="inline-block rounded bg-neutral-100 px-2 py-0.5 text-xs font-mono text-neutral-700"
>
{{ p.hostPort }}:{{ p.containerPort }}
</span>
</div>
<p v-else class="text-sm text-neutral-400 mt-1">-</p>
</div>
</div> </div>
</div> </div>
<!-- Database info -->
<div v-if="dbInfoByEnvId[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.database.title') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-6 gap-3">
<div>
<p class="text-xs text-neutral-400">{{ t('environments.database.status') }}</p>
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
:class="dbInfoByEnvId[env.id!].connected
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'"
>
{{ dbInfoByEnvId[env.id!].connected
? t('environments.database.connected')
: t('environments.database.unreachable') }}
</span>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.database.name') }}</p>
<p class="text-sm font-mono text-neutral-800 mt-1">{{ dbInfoByEnvId[env.id!].name }}</p>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.database.size') }}</p>
<p class="text-sm text-neutral-800 mt-1">{{ dbInfoByEnvId[env.id!].size || '-' }}</p>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.database.tableCount') }}</p>
<p class="text-sm text-neutral-800 mt-1">{{ dbInfoByEnvId[env.id!].tableCount }}</p>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.database.activeConnections') }}</p>
<p class="text-sm text-neutral-800 mt-1">{{ dbInfoByEnvId[env.id!].activeConnections }}</p>
</div>
<div>
<p class="text-xs text-neutral-400">{{ t('environments.database.cacheHitRatio') }}</p>
<p class="text-sm text-neutral-800 mt-1">{{ dbInfoByEnvId[env.id!].cacheHitRatio }}%</p>
</div>
</div>
<p v-if="dbInfoByEnvId[env.id!].largestTable && dbInfoByEnvId[env.id!].largestTable !== '-'" class="text-xs text-neutral-500 mt-2">
{{ t('environments.database.largestTable') }} : <span class="font-mono">{{ dbInfoByEnvId[env.id!].largestTable }}</span>
</p>
</div>
<div class="flex justify-center gap-4 mt-4"> <div class="flex justify-center gap-4 mt-4">
<MalioButton <MalioButton
:label="t('environments.editButton')" :label="t('environments.editButton')"
@@ -440,11 +580,13 @@ onMounted(loadApplication)
<MalioInputText <MalioInputText
v-model="editForm.registryImage" v-model="editForm.registryImage"
:label="t('applications.form.registryImage')" :label="t('applications.form.registryImage')"
hint="Ex : gitea.malio.fr/malio-dev/sirh"
required required
/> />
<MalioInputText <MalioInputText
v-model="editForm.giteaUrl" v-model="editForm.giteaUrl"
:label="t('applications.form.giteaUrl')" :label="t('applications.form.giteaUrl')"
hint="Ex : https://gitea.malio.fr/malio-dev/sirh"
/> />
</div> </div>
<div> <div>
@@ -473,48 +615,65 @@ onMounted(loadApplication)
<MalioInputText <MalioInputText
v-model="envForm.name" v-model="envForm.name"
:label="t('environments.form.name')" :label="t('environments.form.name')"
groupClass="mt-0"
required required
/> />
<MalioInputText <MalioInputText
v-model="envForm.containerName" v-model="envForm.containerName"
:label="t('environments.form.containerName')" :label="t('environments.form.containerName')"
groupClass="mt-0"
required required
/> />
<MalioInputText <MalioInputText
v-model="envForm.deployScriptPath" v-model="envForm.deployScriptPath"
:label="t('environments.form.deployScriptPath')" :label="t('environments.form.deployScriptPath')"
:hint="t('environments.form.pathHint')"
required required
/> />
<MalioInputText <MalioInputText
v-model="envForm.maintenanceFilePath" v-model="envForm.maintenanceFilePath"
:label="t('environments.form.maintenanceFilePath')" :label="t('environments.form.maintenanceFilePath')"
:hint="t('environments.form.pathHint')"
required required
/> />
<MalioInputText <MalioInputText
v-model="envForm.appUrl" v-model="envForm.appUrl"
:label="t('environments.form.appUrl')" :label="t('environments.form.appUrl')"
groupClass="mt-0"
/>
<MalioInputText
v-model="envForm.databaseName"
:label="t('environments.database.formLabel')"
:hint="t('environments.database.formHint')"
groupClass="mt-0"
/> />
</div> </div>
<!-- Log files --> <!-- Log files -->
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-4">
<p class="text-sm font-medium text-neutral-700">{{ t('environments.logFiles.title') }}</p> <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"> <button type="button" @click="addLogFile" class="text-primary-500 hover:underline text-sm font-semibold">
+ {{ t('environments.logFiles.addButton') }} + {{ t('environments.logFiles.addButton') }}
</button> </button>
</div> </div>
<div v-for="(lf, index) in envForm.logFiles" :key="index" class="flex gap-2 mb-2"> <div v-for="(lf, index) in envForm.logFiles" :key="index" class="flex gap-2 mb-2">
<MalioInputText v-model="lf.label" :label="t('environments.logFiles.label')" groupClass="mt-0" inputClass="flex-1" required /> <div class="w-1/3">
<MalioInputText v-model="lf.path" :label="t('environments.logFiles.path')" groupClass="mt-0" inputClass="flex-[2]" required /> <MalioInputText v-model="lf.label" :label="t('environments.logFiles.label')" groupClass="mt-0" inputClass="flex-1" required />
<MalioButtonIcon </div>
icon="mdi:delete-outline" <div class="w-2/3">
:aria-label="t('environments.logFiles.remove')" <MalioInputText v-model="lf.path" :label="t('environments.logFiles.path')" groupClass="mt-0" inputClass="flex-[2]" :hint="`${t('environments.form.pathHint')}/${application?.slug ?? ''}`" required />
variant="ghost" </div>
icon-size="18" <div class="h-[46px] flex items-center">
button-class="text-red-500 hover:bg-red-50 hover:text-red-700 my-1" <MalioButtonIcon
@click="removeLogFile(index)" 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>
</div> </div>
</form> </form>
@@ -597,5 +756,15 @@ onMounted(loadApplication)
/> />
</template> </template>
</AppModal> </AppModal>
<!-- Log modal -->
<LogModal
v-model="showLogModal"
:title="logTitle"
:content="logContent"
:loading="logLoading"
:show-level-filter="logIsSymfony"
@refresh="refreshLogs"
/>
</div> </div>
</template> </template>

View File

@@ -130,11 +130,13 @@ onMounted(loadApplications)
<MalioInputText <MalioInputText
v-model="createForm.registryImage" v-model="createForm.registryImage"
:label="t('applications.form.registryImage')" :label="t('applications.form.registryImage')"
hint="Ex : gitea.malio.fr/malio-dev/sirh"
required required
/> />
<MalioInputText <MalioInputText
v-model="createForm.giteaUrl" v-model="createForm.giteaUrl"
:label="t('applications.form.giteaUrl')" :label="t('applications.form.giteaUrl')"
hint="Ex : https://gitea.malio.fr/malio-dev/sirh"
/> />
</div> </div>
<div> <div>

View File

@@ -20,18 +20,12 @@
v-model="username" v-model="username"
/> />
<div> <MalioInputPassword
<label class="text-sm font-semibold text-neutral-700" for="password"> v-model="password"
Mot de passe label="Mot de passe"
</label> autocomplete="current-password"
<input inputClass="w-full"
id="password" />
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<MalioButton <MalioButton
label="Se connecter" label="Se connecter"

View File

@@ -1,4 +1,4 @@
import type { DashboardResponse, EnvironmentHealth } from './dto/dashboard' import type { DashboardResponse, EnvironmentHealth, DatabaseInfo } from './dto/dashboard'
export function getDashboard(): Promise<DashboardResponse> { export function getDashboard(): Promise<DashboardResponse> {
return useApi().get<DashboardResponse>('/dashboard', undefined, { return useApi().get<DashboardResponse>('/dashboard', undefined, {
@@ -11,3 +11,9 @@ export function getEnvironmentHealth(envId: number): Promise<EnvironmentHealth>
toast: false, toast: false,
}) })
} }
export function getDatabaseInfo(envId: number): Promise<DatabaseInfo> {
return useApi().get<DatabaseInfo>(`/environments/${envId}/database`, undefined, {
toast: false,
})
}

View File

@@ -12,6 +12,7 @@ type Environment = {
deployScriptPath: string deployScriptPath: string
maintenanceFilePath: string maintenanceFilePath: string
appUrl?: string appUrl?: string
databaseName?: string
logFiles: LogFile[] logFiles: LogFile[]
maintenance: boolean maintenance: boolean
} }
@@ -22,6 +23,7 @@ type EnvironmentWrite = {
deployScriptPath: string deployScriptPath: string
maintenanceFilePath: string maintenanceFilePath: string
appUrl?: string appUrl?: string
databaseName?: string
logFiles: LogFile[] logFiles: LogFile[]
} }

View File

@@ -16,6 +16,22 @@ type DashboardResponse = {
applications: DashboardApplication[] applications: DashboardApplication[]
} }
type PortMapping = {
hostPort: string
containerPort: string
protocol: string
}
type DatabaseInfo = {
connected: boolean
name: string
size: string
tableCount: number
activeConnections: number
cacheHitRatio: number
largestTable: string
}
type EnvironmentHealth = { type EnvironmentHealth = {
status: string status: string
version: string version: string
@@ -24,4 +40,5 @@ type EnvironmentHealth = {
memoryUsage: string memoryUsage: string
memoryLimit: string memoryLimit: string
memoryPercent: number memoryPercent: number
ports: PortMapping[]
} }

View File

@@ -0,0 +1,5 @@
type LogOutput = {
content: string
lines: number
source: string
}

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

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

View File

@@ -40,10 +40,16 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \ libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor docker.io \ nginx supervisor docker.io curl \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \ && docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Docker Compose plugin
RUN DOCKER_COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep '"tag_name"' | sed 's/.*"v\(.*\)".*/\1/') \
&& mkdir -p /usr/local/lib/docker/cli-plugins \
&& curl -SL "https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64" -o /usr/local/lib/docker/cli-plugins/docker-compose \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
# PHP production config # PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
@@ -71,10 +77,12 @@ 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) # 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 RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions # Permissions + directories
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \ RUN mkdir -p /var/www/html/var/log /var/www/html/var/uploads \
/var/www/maintenance/sirh /var/www/maintenance/lesstime /var/www/maintenance/inventory \ && chown -R www-data:www-data /var/www/html/var
&& chown -R www-data:www-data /var/www/html/var /var/www/maintenance
# 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 WORKDIR /var/www/html
EXPOSE 80 EXPOSE 80

View File

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

View File

@@ -0,0 +1,31 @@
<?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 Version20260408135722 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('ALTER TABLE environment ADD database_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE environment DROP database_name');
}
}

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\DatabaseInfoProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/environments/{id}/database',
security: "is_granted('ROLE_ADMIN')",
provider: DatabaseInfoProvider::class,
),
],
)]
final class DatabaseInfo
{
public bool $connected = false;
public string $name = '';
public string $size = '';
public int $tableCount = 0;
public int $activeConnections = 0;
public float $cacheHitRatio = 0.0;
public string $largestTable = '';
}

View File

@@ -26,4 +26,6 @@ final class EnvironmentHealth
public string $memoryUsage = ''; public string $memoryUsage = '';
public string $memoryLimit = ''; public string $memoryLimit = '';
public float $memoryPercent = 0.0; public float $memoryPercent = 0.0;
/** @var list<array{hostPort: string, containerPort: string, protocol: string}> */
public array $ports = [];
} }

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

@@ -57,21 +57,21 @@ class AppFixtures extends Fixture
$sirh->setGiteaUrl('https://gitea.malio.fr/malio-dev/sirh'); $sirh->setGiteaUrl('https://gitea.malio.fr/malio-dev/sirh');
$sirhProd = new Environment(); $sirhProd = new Environment();
$sirhProd->setName('production'); $sirhProd->setName('Production');
$sirhProd->setContainerName('sirh-app'); $sirhProd->setContainerName('php-sirh-fpm');
$sirhProd->setDeployScriptPath('/home/m-tristan/workspace/SIRH/deploy/docker/deploy.sh'); $sirhProd->setDeployScriptPath('/SIRH/deploy/docker/deploy.sh');
$sirhProd->setMaintenanceFilePath('/home/m-tristan/workspace/SIRH/deploy/docker/maintenance.on'); $sirhProd->setMaintenanceFilePath('/SIRH/deploy/docker/maintenance.on');
$sirhProd->setAppUrl('http://sirh.malio-dev.fr'); $sirhProd->setAppUrl('http://sirh.malio-dev.fr');
$sirh->addEnvironment($sirhProd); $sirh->addEnvironment($sirhProd);
$sirhProdLog = new LogFile(); $sirhProdLog = new LogFile();
$sirhProdLog->setLabel('prod'); $sirhProdLog->setLabel('dev');
$sirhProdLog->setPath('/home/m-tristan/workspace/SIRH/var/log/prod.log'); $sirhProdLog->setPath('/SIRH/var/log/dev.log');
$sirhProd->addLogFile($sirhProdLog); $sirhProd->addLogFile($sirhProdLog);
$sirhCronLog = new LogFile(); $sirhCronLog = new LogFile();
$sirhCronLog->setLabel('cron'); $sirhCronLog->setLabel('cron');
$sirhCronLog->setPath('/home/m-tristan/workspace/SIRH/var/log/cron.log'); $sirhCronLog->setPath('/SIRH/var/log/cron.log');
$sirhProd->addLogFile($sirhCronLog); $sirhProd->addLogFile($sirhCronLog);
$manager->persist($sirh); $manager->persist($sirh);
@@ -86,8 +86,8 @@ class AppFixtures extends Fixture
$lesstimeProd = new Environment(); $lesstimeProd = new Environment();
$lesstimeProd->setName('production'); $lesstimeProd->setName('production');
$lesstimeProd->setContainerName('lesstime-app'); $lesstimeProd->setContainerName('lesstime-app');
$lesstimeProd->setDeployScriptPath('/home/m-tristan/workspace/lesstime/deploy/docker/deploy.sh'); $lesstimeProd->setDeployScriptPath('/lesstime/deploy/docker/deploy.sh');
$lesstimeProd->setMaintenanceFilePath('/home/m-tristan/workspace/lesstime/deploy/docker/maintenance.on'); $lesstimeProd->setMaintenanceFilePath('/lesstime/deploy/docker/maintenance.on');
$lesstimeProd->setAppUrl('http://lesstime.malio-dev.fr'); $lesstimeProd->setAppUrl('http://lesstime.malio-dev.fr');
$lesstime->addEnvironment($lesstimeProd); $lesstime->addEnvironment($lesstimeProd);
@@ -103,16 +103,16 @@ class AppFixtures extends Fixture
$inventoryProd = new Environment(); $inventoryProd = new Environment();
$inventoryProd->setName('production'); $inventoryProd->setName('production');
$inventoryProd->setContainerName('inventory-app'); $inventoryProd->setContainerName('inventory-app');
$inventoryProd->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy.sh'); $inventoryProd->setDeployScriptPath('/inventory/deploy/docker/deploy.sh');
$inventoryProd->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance.on'); $inventoryProd->setMaintenanceFilePath('/inventory/deploy/docker/maintenance.on');
$inventoryProd->setAppUrl('http://inventory.malio-dev.fr'); $inventoryProd->setAppUrl('http://inventory.malio-dev.fr');
$inventory->addEnvironment($inventoryProd); $inventory->addEnvironment($inventoryProd);
$inventoryRecette = new Environment(); $inventoryRecette = new Environment();
$inventoryRecette->setName('recette'); $inventoryRecette->setName('recette');
$inventoryRecette->setContainerName('inventory-test-app'); $inventoryRecette->setContainerName('inventory-test-app');
$inventoryRecette->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy-test.sh'); $inventoryRecette->setDeployScriptPath('/inventory/deploy/docker/deploy-test.sh');
$inventoryRecette->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance-test.on'); $inventoryRecette->setMaintenanceFilePath('/inventory/deploy/docker/maintenance-test.on');
$inventoryRecette->setAppUrl('http://inventory-test.malio-dev.fr'); $inventoryRecette->setAppUrl('http://inventory-test.malio-dev.fr');
$inventory->addEnvironment($inventoryRecette); $inventory->addEnvironment($inventoryRecette);

View File

@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Repository\ApplicationRepository; use App\Repository\ApplicationRepository;
use App\State\ApplicationProvider;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -24,11 +25,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
new GetCollection( new GetCollection(
normalizationContext: ['groups' => ['app:read']], normalizationContext: ['groups' => ['app:read']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
provider: ApplicationProvider::class,
), ),
new Get( new Get(
uriVariables: ['slug'], uriVariables: ['slug'],
normalizationContext: ['groups' => ['app:read', 'app:detail']], normalizationContext: ['groups' => ['app:read', 'app:detail']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
provider: ApplicationProvider::class,
), ),
new Post( new Post(
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",

View File

@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Repository\EnvironmentRepository; use App\Repository\EnvironmentRepository;
use App\State\EnvironmentCreateProcessor;
use App\State\MaintenanceToggleProcessor; use App\State\MaintenanceToggleProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -21,9 +22,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
new Post( new Post(
uriTemplate: '/applications/{slug}/environments', uriTemplate: '/applications/{slug}/environments',
uriVariables: [ uriVariables: [
'slug' => new Link(fromClass: Application::class, fromProperty: 'environments'), 'slug' => new Link(toProperty: 'application', fromClass: Application::class, identifiers: ['slug']),
], ],
read: false,
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: EnvironmentCreateProcessor::class,
), ),
new Patch( new Patch(
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
@@ -70,6 +73,10 @@ class Environment
#[Groups(['env:read', 'env:write', 'app:detail'])] #[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $appUrl = null; private ?string $appUrl = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['env:read', 'env:write', 'app:detail'])]
private ?string $databaseName = null;
#[ORM\ManyToOne(targetEntity: Application::class, inversedBy: 'environments')] #[ORM\ManyToOne(targetEntity: Application::class, inversedBy: 'environments')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
private ?Application $application = null; private ?Application $application = null;
@@ -152,6 +159,18 @@ class Environment
return $this; return $this;
} }
public function getDatabaseName(): ?string
{
return $this->databaseName;
}
public function setDatabaseName(?string $databaseName): static
{
$this->databaseName = $databaseName;
return $this;
}
public function getApplication(): ?Application public function getApplication(): ?Application
{ {
return $this->application; return $this->application;
@@ -193,6 +212,13 @@ class Environment
public function getMaintenance(): bool public function getMaintenance(): bool
{ {
return file_exists((string) $this->maintenanceFilePath); return $this->maintenance ?? false;
}
public function setMaintenance(bool $maintenance): static
{
$this->maintenance = $maintenance;
return $this;
} }
} }

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,139 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
final class DatabaseService
{
public function __construct(
private readonly Connection $connection,
) {}
/**
* @return array{connected: bool, name: string, size: string, tableCount: int, activeConnections: int, cacheHitRatio: float, largestTable: string}
*/
public function getDatabaseInfo(string $databaseName): array
{
$fallback = [
'connected' => false,
'name' => $databaseName,
'size' => '',
'tableCount' => 0,
'activeConnections' => 0,
'cacheHitRatio' => 0.0,
'largestTable' => '',
];
try {
// Check database exists (cross-database query via system catalog)
$exists = $this->connection->fetchOne(
'SELECT 1 FROM pg_database WHERE datname = :dbname',
['dbname' => $databaseName]
);
if (!$exists) {
return $fallback;
}
// Database size (cross-database, works from any connection)
$sizeBytes = (int) $this->connection->fetchOne(
'SELECT pg_database_size(:dbname)',
['dbname' => $databaseName]
);
$size = $this->formatBytes($sizeBytes);
// Active connections (cross-database system view)
$activeConnections = (int) $this->connection->fetchOne(
'SELECT count(*) FROM pg_stat_activity WHERE datname = :dbname',
['dbname' => $databaseName]
);
// Cache hit ratio (cross-database system view)
$cacheHitRatio = (float) ($this->connection->fetchOne(
'SELECT round(100.0 * sum(blks_hit) / nullif(sum(blks_hit + blks_read), 0), 2) FROM pg_stat_database WHERE datname = :dbname',
['dbname' => $databaseName]
) ?? 0);
// Connect to the target database for table-specific queries
$targetConn = $this->connectToDatabase($databaseName);
try {
// Table count (must query target database's information_schema)
$tableCount = (int) $targetConn->fetchOne(
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_catalog = :dbname",
['dbname' => $databaseName]
);
// Largest table (must query target database's pg_class)
$largestTable = $this->fetchLargestTable($targetConn);
} finally {
$targetConn->close();
}
return [
'connected' => true,
'name' => $databaseName,
'size' => $size,
'tableCount' => $tableCount,
'activeConnections' => $activeConnections,
'cacheHitRatio' => $cacheHitRatio,
'largestTable' => $largestTable,
];
} catch (\Throwable) {
return $fallback;
}
}
private function connectToDatabase(string $databaseName): Connection
{
$params = $this->connection->getParams();
$params['dbname'] = $databaseName;
return DriverManager::getConnection($params);
}
private function fetchLargestTable(Connection $conn): string
{
try {
$row = $conn->fetchAssociative(
"SELECT relname, pg_total_relation_size(c.oid) as total_size
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 1"
);
if (!$row) {
return '-';
}
return $row['relname'] . ' (' . $this->formatBytes((int) $row['total_size']) . ')';
} catch (\Throwable) {
return '-';
}
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
$units = ['KB', 'MB', 'GB', 'TB'];
$value = (float) $bytes;
foreach ($units as $unit) {
$value /= 1024;
if ($value < 1024) {
return round($value, 1) . ' ' . $unit;
}
}
return round($value, 1) . ' TB';
}
}

View File

@@ -7,19 +7,33 @@ namespace App\Service;
use App\Entity\Environment; use App\Entity\Environment;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
final class DeployService final readonly class DeployService
{ {
public function __construct(
private AppPathResolver $pathResolver,
) {}
/** /**
* @return array{success: bool, output: string, exitCode: int} * @return array{success: bool, output: string, exitCode: int}
*/ */
public function deploy(Environment $environment, string $tag): array public function deploy(Environment $environment, string $tag): array
{ {
$scriptPath = $environment->getDeployScriptPath(); $relativePath = $environment->getDeployScriptPath();
if (null === $scriptPath || !file_exists($scriptPath)) { if (null === $relativePath) {
return [ return [
'success' => false, 'success' => false,
'output' => sprintf('Deploy script not found: %s', $scriptPath ?? 'null'), '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, 'exitCode' => 1,
]; ];
} }

View File

@@ -8,14 +8,38 @@ use Symfony\Component\Process\Process;
final class DockerService 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} * @return array{status: string, image: string, version: string, startedAt: string, ports: list<array{hostPort: string, containerPort: string, protocol: string}>}
*/ */
public function getContainerStatus(string $containerName): array public function getContainerStatus(string $containerName): array
{ {
if (!$this->isDockerAvailable()) {
return [
'status' => 'unavailable',
'image' => '',
'version' => '',
'startedAt' => '',
'ports' => [],
];
}
$process = new Process([ $process = new Process([
'docker', 'inspect', 'docker', 'inspect',
'--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}', '--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}||{{json .NetworkSettings.Ports}}',
$containerName, $containerName,
]); ]);
$process->setTimeout(10); $process->setTimeout(10);
@@ -27,10 +51,11 @@ final class DockerService
'image' => '', 'image' => '',
'version' => '', 'version' => '',
'startedAt' => '', 'startedAt' => '',
'ports' => [],
]; ];
} }
$parts = explode('||', trim($process->getOutput())); $parts = explode('||', trim($process->getOutput()), 4);
if (\count($parts) < 3) { if (\count($parts) < 3) {
return [ return [
@@ -38,6 +63,7 @@ final class DockerService
'image' => '', 'image' => '',
'version' => '', 'version' => '',
'startedAt' => '', 'startedAt' => '',
'ports' => [],
]; ];
} }
@@ -47,11 +73,32 @@ final class DockerService
$version = substr($image, strrpos($image, ':') + 1); $version = substr($image, strrpos($image, ':') + 1);
} }
$ports = [];
if (isset($parts[3])) {
$portsJson = json_decode($parts[3], true);
if (\is_array($portsJson)) {
foreach ($portsJson as $containerPort => $bindings) {
if (!\is_array($bindings)) {
continue;
}
[$port, $protocol] = explode('/', $containerPort) + [1 => 'tcp'];
foreach ($bindings as $binding) {
$ports[] = [
'hostPort' => $binding['HostPort'] ?? '',
'containerPort' => $port,
'protocol' => $protocol,
];
}
}
}
}
return [ return [
'status' => $parts[0], 'status' => $parts[0],
'image' => $image, 'image' => $image,
'version' => $version, 'version' => $version,
'startedAt' => $parts[2], 'startedAt' => $parts[2],
'ports' => $ports,
]; ];
} }
@@ -60,6 +107,15 @@ final class DockerService
*/ */
public function getContainerStats(string $containerName): array public function getContainerStats(string $containerName): array
{ {
if (!$this->isDockerAvailable()) {
return [
'cpuPercent' => 0.0,
'memoryUsage' => '',
'memoryLimit' => '',
'memoryPercent' => 0.0,
];
}
$process = new Process([ $process = new Process([
'docker', 'stats', '--no-stream', 'docker', 'stats', '--no-stream',
'--format', '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}', '--format', '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}',

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,49 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\DatabaseInfo;
use App\Repository\EnvironmentRepository;
use App\Service\DatabaseService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class DatabaseInfoProvider implements ProviderInterface
{
public function __construct(
private EnvironmentRepository $environmentRepository,
private DatabaseService $databaseService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): DatabaseInfo
{
$id = $uriVariables['id'] ?? null;
$environment = $id ? $this->environmentRepository->find($id) : null;
if (null === $environment) {
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
}
$databaseName = $environment->getDatabaseName();
if (null === $databaseName || '' === $databaseName) {
throw new NotFoundHttpException('No database configured for this environment.');
}
$info = $this->databaseService->getDatabaseInfo($databaseName);
$dto = new DatabaseInfo();
$dto->connected = $info['connected'];
$dto->name = $info['name'];
$dto->size = $info['size'];
$dto->tableCount = $info['tableCount'];
$dto->activeConnections = $info['activeConnections'];
$dto->cacheHitRatio = $info['cacheHitRatio'];
$dto->largestTable = $info['largestTable'];
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

@@ -39,6 +39,7 @@ final readonly class EnvironmentHealthProvider implements ProviderInterface
$dto->memoryUsage = $stats['memoryUsage']; $dto->memoryUsage = $stats['memoryUsage'];
$dto->memoryLimit = $stats['memoryLimit']; $dto->memoryLimit = $stats['memoryLimit'];
$dto->memoryPercent = $stats['memoryPercent']; $dto->memoryPercent = $stats['memoryPercent'];
$dto->ports = $status['ports'];
return $dto; return $dto;
} }

View File

@@ -7,21 +7,27 @@ namespace App\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\Environment; use App\Entity\Environment;
use App\Service\AppPathResolver;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class MaintenanceToggleProcessor implements ProcessorInterface final readonly class MaintenanceToggleProcessor implements ProcessorInterface
{ {
public function __construct(
private AppPathResolver $pathResolver,
) {}
/** /**
* @param Environment $data * @param Environment $data
*/ */
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Environment public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Environment
{ {
$maintenancePath = $data->getMaintenanceFilePath(); $relativePath = $data->getMaintenanceFilePath();
if (null === $maintenancePath) { if (null === $relativePath) {
throw new BadRequestHttpException('Maintenance file path is not configured for this environment.'); throw new BadRequestHttpException('Maintenance file path is not configured for this environment.');
} }
$maintenancePath = $this->pathResolver->resolve($relativePath);
$requestData = $context['request']?->toArray() ?? []; $requestData = $context['request']?->toArray() ?? [];
$enableMaintenance = $requestData['maintenance'] ?? false; $enableMaintenance = $requestData['maintenance'] ?? false;

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