21 Commits

Author SHA1 Message Date
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
21 changed files with 506 additions and 35 deletions

View File

@@ -31,6 +31,10 @@ frontend/services/dto/ # Types TypeScript
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
```bash

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.21'
app.version: '0.1.28'

View File

@@ -119,6 +119,7 @@
"deployScriptPath": "Chemin du script de deploiement",
"maintenanceFilePath": "Chemin du fichier de maintenance",
"pathHint": "Prefixe automatique : /mnt/apps",
"pathHintLog": "Chemin dans le container, ex : var/log/prod.log",
"appUrl": "URL de l'application",
"save": "Enregistrer",
"cancel": "Annuler"
@@ -137,8 +138,23 @@
"uptime": "Uptime",
"cpu": "CPU",
"memory": "Memoire",
"ports": "Ports",
"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": {
"button": "Deployer",
"title": "Deployer une version",

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ import { getApplication, updateApplication, deleteApplication } from '~/services
import { createEnvironment, updateEnvironment, deleteEnvironment, toggleMaintenance } from '~/services/environments'
import type { DeployResult } from '~/services/dto/deploy'
import { getAvailableTags, deploy } from '~/services/deploy'
import type { EnvironmentHealth } from '~/services/dto/dashboard'
import { getEnvironmentHealth } from '~/services/dashboard'
import type { EnvironmentHealth, DatabaseInfo } from '~/services/dto/dashboard'
import { getEnvironmentHealth, getDatabaseInfo } from '~/services/dashboard'
import type { LogOutput } from '~/services/dto/logs'
import { getDockerLogs, getSymfonyLog } from '~/services/logs'
@@ -31,6 +31,9 @@ const deployResult = ref<DeployResult | null>(null)
const healthByEnvId = ref<Record<number, EnvironmentHealth>>({})
const loadingHealth = ref(false)
// Database info per env
const dbInfoByEnvId = ref<Record<number, DatabaseInfo>>({})
// Log modals
const showLogModal = ref(false)
const logContent = ref('')
@@ -54,6 +57,7 @@ const envForm = ref<EnvironmentWrite>({
deployScriptPath: '',
maintenanceFilePath: '',
appUrl: '',
databaseName: '',
logFiles: [],
})
const isSubmittingEnv = ref(false)
@@ -66,6 +70,7 @@ async function loadApplication() {
loading.value = false
}
loadHealthData()
loadDatabaseData()
}
// Application edit
@@ -103,7 +108,7 @@ async function handleDeleteApp() {
// Environment CRUD
function openCreateEnvModal() {
editingEnvId.value = null
envForm.value = { name: '', containerName: '', deployScriptPath: '', maintenanceFilePath: '', appUrl: '', logFiles: [] }
envForm.value = { name: '', containerName: '', deployScriptPath: '', maintenanceFilePath: '', appUrl: '', databaseName: '', logFiles: [] }
showEnvModal.value = true
}
@@ -115,6 +120,7 @@ function openEditEnvModal(env: Environment) {
deployScriptPath: env.deployScriptPath,
maintenanceFilePath: env.maintenanceFilePath,
appUrl: env.appUrl ?? '',
databaseName: env.databaseName ?? '',
logFiles: env.logFiles.map(lf => ({ label: lf.label, path: lf.path })),
}
showEnvModal.value = true
@@ -215,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 {
if (!startedAt) return '-'
const start = new Date(startedAt)
@@ -427,7 +447,7 @@ onMounted(loadApplication)
<!-- Health metrics -->
<div v-if="healthByEnvId[env.id!]" class="mt-4 border-t border-neutral-200 py-3">
<p class="text-sm font-bold uppercase tracking-wider mb-2">{{ t('environments.health.title') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div class="grid grid-cols-2 sm:grid-cols-6 gap-3">
<div>
<p class="text-xs text-neutral-400">{{ t('environments.health.status') }}</p>
<span
@@ -456,8 +476,65 @@ onMounted(loadApplication)
<span class="text-neutral-400">({{ healthByEnvId[env.id!].memoryPercent }}%)</span>
</p>
</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>
<!-- 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">
<MalioButton
:label="t('environments.editButton')"
@@ -564,6 +641,12 @@ onMounted(loadApplication)
: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>
<!-- Log files -->
@@ -579,7 +662,7 @@ onMounted(loadApplication)
<MalioInputText v-model="lf.label" :label="t('environments.logFiles.label')" groupClass="mt-0" inputClass="flex-1" required />
</div>
<div class="w-2/3">
<MalioInputText v-model="lf.path" :label="t('environments.logFiles.path')" groupClass="mt-0" inputClass="flex-[2]" :hint="t('environments.form.pathHint')" required />
<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

View File

@@ -20,18 +20,12 @@
v-model="username"
/>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
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>
<MalioInputPassword
v-model="password"
label="Mot de passe"
autocomplete="current-password"
inputClass="w-full"
/>
<MalioButton
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> {
return useApi().get<DashboardResponse>('/dashboard', undefined, {
@@ -11,3 +11,9 @@ export function getEnvironmentHealth(envId: number): Promise<EnvironmentHealth>
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
maintenanceFilePath: string
appUrl?: string
databaseName?: string
logFiles: LogFile[]
maintenance: boolean
}
@@ -22,6 +23,7 @@ type EnvironmentWrite = {
deployScriptPath: string
maintenanceFilePath: string
appUrl?: string
databaseName?: string
logFiles: LogFile[]
}

View File

@@ -16,6 +16,22 @@ type DashboardResponse = {
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 = {
status: string
version: string
@@ -24,4 +40,5 @@ type EnvironmentHealth = {
memoryUsage: string
memoryLimit: string
memoryPercent: number
ports: PortMapping[]
}

View File

@@ -40,10 +40,16 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor docker.io \
nginx supervisor docker.io curl \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& 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
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

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 $memoryLimit = '';
public float $memoryPercent = 0.0;
/** @var list<array{hostPort: string, containerPort: string, protocol: string}> */
public array $ports = [];
}

View File

@@ -73,6 +73,10 @@ class Environment
#[Groups(['env:read', 'env:write', 'app:detail'])]
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\JoinColumn(nullable: false)]
private ?Application $application = null;
@@ -155,6 +159,18 @@ class Environment
return $this;
}
public function getDatabaseName(): ?string
{
return $this->databaseName;
}
public function setDatabaseName(?string $databaseName): static
{
$this->databaseName = $databaseName;
return $this;
}
public function getApplication(): ?Application
{
return $this->application;

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Doctrine\DBAL\Connection;
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
$exists = $this->connection->fetchOne(
'SELECT 1 FROM pg_database WHERE datname = :dbname',
['dbname' => $databaseName]
);
if (!$exists) {
return $fallback;
}
// Database size
$sizeBytes = (int) $this->connection->fetchOne(
'SELECT pg_database_size(:dbname)',
['dbname' => $databaseName]
);
$size = $this->formatBytes($sizeBytes);
// Table count
$tableCount = (int) $this->connection->fetchOne(
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_catalog = :dbname",
['dbname' => $databaseName]
);
// Active connections
$activeConnections = (int) $this->connection->fetchOne(
'SELECT count(*) FROM pg_stat_activity WHERE datname = :dbname',
['dbname' => $databaseName]
);
// Cache hit ratio
$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);
// Largest table — requires querying the target database catalog
$largestTable = $this->fetchLargestTable($databaseName);
return [
'connected' => true,
'name' => $databaseName,
'size' => $size,
'tableCount' => $tableCount,
'activeConnections' => $activeConnections,
'cacheHitRatio' => $cacheHitRatio,
'largestTable' => $largestTable,
];
} catch (\Throwable) {
return $fallback;
}
}
private function fetchLargestTable(string $databaseName): string
{
try {
$row = $this->connection->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'
AND c.relowner = (SELECT oid FROM pg_roles WHERE rolname = current_user)
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

@@ -23,7 +23,7 @@ final class DockerService
}
/**
* @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
{
@@ -33,12 +33,13 @@ final class DockerService
'image' => '',
'version' => '',
'startedAt' => '',
'ports' => [],
];
}
$process = new Process([
'docker', 'inspect',
'--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}',
'--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}||{{json .NetworkSettings.Ports}}',
$containerName,
]);
$process->setTimeout(10);
@@ -50,10 +51,11 @@ final class DockerService
'image' => '',
'version' => '',
'startedAt' => '',
'ports' => [],
];
}
$parts = explode('||', trim($process->getOutput()));
$parts = explode('||', trim($process->getOutput()), 4);
if (\count($parts) < 3) {
return [
@@ -61,6 +63,7 @@ final class DockerService
'image' => '',
'version' => '',
'startedAt' => '',
'ports' => [],
];
}
@@ -70,11 +73,32 @@ final class DockerService
$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 [
'status' => $parts[0],
'image' => $image,
'version' => $version,
'startedAt' => $parts[2],
'ports' => $ports,
];
}

View File

@@ -35,25 +35,91 @@ final readonly class LogService
$process->setTimeout(10);
$process->run();
return $process->getOutput() . $process->getErrorOutput();
$output = $process->getOutput() . $process->getErrorOutput();
return $this->formatDockerOutput($output);
}
public function getSymfonyLog(string $relativePath, int $lines = 100, ?string $level = null): string
private function formatDockerOutput(string $output): string
{
$path = $this->pathResolver->resolve($relativePath);
$rawLines = explode("\n", trim($output));
$formatted = [];
if (!file_exists($path)) {
return sprintf('Log file not found: %s', $path);
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(['tail', '-n', (string) $readLines, $path]);
$process = new Process(['docker', 'exec', $containerName, 'tail', '-n', (string) $readLines, $logPath]);
$process->setTimeout(10);
$process->run();
$rawLines = explode("\n", trim($process->getOutput()));
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) {

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

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

View File

@@ -42,6 +42,7 @@ final readonly class SymfonyLogProvider implements ProviderInterface
$level = $request?->query->get('level');
$content = $this->logService->getSymfonyLog(
$environment->getContainerName(),
$logFile->getPath(),
$lines,
$level,