Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2145d2cf9b | ||
|
|
ce744b3aba | ||
|
|
838378a409 | ||
|
|
95c90a258f | ||
|
|
20d6dcea45 | ||
|
|
1cb2ff2130 | ||
|
|
df755d521c | ||
|
|
0019b5987d | ||
|
|
41d6405872 | ||
|
|
e0ab5b5961 | ||
|
|
560734d72c | ||
|
|
18589823f3 | ||
|
|
ab2b3fd9ef | ||
|
|
123d9b306f | ||
|
|
ca3445103d | ||
| 18f3de1ba9 | |||
| 52571c651f | |||
|
|
b9712643de | ||
| e954402959 | |||
|
|
98d9032068 | ||
| 5f6277d412 | |||
|
|
8fb71e6370 | ||
| e128b45caa | |||
|
|
d331ef4577 | ||
| b769abdbe1 | |||
|
|
7e342c9aeb | ||
| 419d3b24cb | |||
|
|
777224709d | ||
| 0a4b0cdc14 | |||
| 3fd745196f | |||
| 8481fe8fef | |||
| 061ab13d2b | |||
|
|
656aaf816f | ||
| 36777e50a8 | |||
| fdd1182caf | |||
| 98237daa56 |
4
.env
4
.env
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
132
composer.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.17'
|
app.version: '0.1.29'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
113
frontend/components/ui/LogModal.vue
Normal file
113
frontend/components/ui/LogModal.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,32 +322,35 @@ 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"
|
||||||
|
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"
|
@click="handleDeleteApp"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Application info -->
|
<!-- Application info -->
|
||||||
<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,40 +615,56 @@ 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">
|
||||||
|
<div class="w-1/3">
|
||||||
<MalioInputText v-model="lf.label" :label="t('environments.logFiles.label')" groupClass="mt-0" inputClass="flex-1" required />
|
<MalioInputText v-model="lf.label" :label="t('environments.logFiles.label')" groupClass="mt-0" inputClass="flex-1" required />
|
||||||
<MalioInputText v-model="lf.path" :label="t('environments.logFiles.path')" groupClass="mt-0" inputClass="flex-[2]" required />
|
</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
|
<MalioButtonIcon
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
:aria-label="t('environments.logFiles.remove')"
|
:aria-label="t('environments.logFiles.remove')"
|
||||||
@@ -517,6 +675,7 @@ onMounted(loadApplication)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -20,18 +20,12 @@
|
|||||||
v-model="username"
|
v-model="username"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<MalioInputPassword
|
||||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
|
||||||
Mot de passe
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
v-model="password"
|
||||||
type="password"
|
label="Mot de passe"
|
||||||
autocomplete="current-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"
|
inputClass="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Se connecter"
|
label="Se connecter"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
5
frontend/services/dto/logs.ts
Normal file
5
frontend/services/dto/logs.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type LogOutput = {
|
||||||
|
content: string
|
||||||
|
lines: number
|
||||||
|
source: string
|
||||||
|
}
|
||||||
15
frontend/services/logs.ts
Normal file
15
frontend/services/logs.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -75,10 +81,8 @@ RUN echo "APP_ENV=prod" > /var/www/html/.env
|
|||||||
RUN mkdir -p /var/www/html/var/log /var/www/html/var/uploads \
|
RUN mkdir -p /var/www/html/var/log /var/www/html/var/uploads \
|
||||||
&& chown -R www-data:www-data /var/www/html/var
|
&& chown -R www-data:www-data /var/www/html/var
|
||||||
|
|
||||||
# Allow www-data to use Docker socket
|
# Allow www-data to use Docker socket (GID 987 matches host's docker group)
|
||||||
# The socket GID varies per host; we set it at container startup via entrypoint
|
RUN groupadd -g 987 dockerhost 2>/dev/null; usermod -aG dockerhost www-data
|
||||||
# As fallback, install docker group with common GID
|
|
||||||
RUN groupadd -g 999 docker 2>/dev/null; usermod -aG docker www-data
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "8084:80"
|
- "8084:80"
|
||||||
|
group_add:
|
||||||
|
- "987"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||||
- ./uploads:/var/www/html/var/uploads
|
- ./uploads:/var/www/html/var/uploads
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /var/www/sirh:/var/www/sirh
|
- /var/www:/mnt/apps
|
||||||
- /var/www/lesstime:/var/www/lesstime
|
|
||||||
- /var/www/inventory:/var/www/inventory
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
31
migrations/Version20260408135722.php
Normal file
31
migrations/Version20260408135722.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/ApiResource/DatabaseInfo.php
Normal file
29
src/ApiResource/DatabaseInfo.php
Normal 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 = '';
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/ApiResource/LogOutput.php
Normal file
31
src/ApiResource/LogOutput.php
Normal 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 = '';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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')",
|
||||||
|
|||||||
@@ -73,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;
|
||||||
@@ -155,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;
|
||||||
@@ -196,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/Service/AppPathResolver.php
Normal file
20
src/Service/AppPathResolver.php
Normal 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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/Service/DatabaseService.php
Normal file
139
src/Service/DatabaseService.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
179
src/Service/LogService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/State/ApplicationProvider.php
Normal file
54
src/State/ApplicationProvider.php
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/State/DatabaseInfoProvider.php
Normal file
49
src/State/DatabaseInfoProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/State/DockerLogProvider.php
Normal file
49
src/State/DockerLogProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
58
src/State/SymfonyLogProvider.php
Normal file
58
src/State/SymfonyLogProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user