Files
Central/docs/superpowers/plans/2026-04-06-phase2b-dashboard-sante.md
tristan 8f585b4be8
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
feat/ajout-de-fonctionnalites (#1)
Reviewed-on: #1
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-06 14:23:20 +00:00

24 KiB

Phase 2b — Dashboard Sante Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a dashboard showing container health status (running/stopped) and version for all environments, plus detailed metrics (CPU, memory, uptime) on the application detail page.

Architecture: A DockerService executes Docker CLI commands via Process to get container status and stats. Two new API Platform endpoints expose dashboard overview and per-environment health. Frontend adds a new dashboard page and enriches the detail page.

Tech Stack: PHP 8.4, Symfony 8, API Platform 4, Symfony Process, Docker CLI, Nuxt 4, Vue 3


File Structure

Backend — Create

File Responsibility
src/Service/DockerService.php Execute Docker CLI commands via Process
src/ApiResource/Dashboard.php API Platform DTO for dashboard response
src/ApiResource/EnvironmentHealth.php API Platform DTO for env health response
src/State/DashboardProvider.php Provider that aggregates all apps + container status
src/State/EnvironmentHealthProvider.php Provider for single env detailed health

Frontend — Create

File Responsibility
frontend/services/dashboard.ts API calls for dashboard and env health
frontend/services/dto/dashboard.ts TypeScript types
frontend/pages/dashboard.vue Dashboard page

Frontend — Modify

File Change
frontend/pages/applications/[slug].vue Add health metrics block per env
frontend/layouts/default.vue Add Dashboard sidebar link
frontend/middleware/auth.global.ts Redirect / to /dashboard instead of /applications
frontend/i18n/locales/fr.json Dashboard translation keys

Task 1: DockerService

Files:

  • Create: src/Service/DockerService.php

  • Step 1: Create the service

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\Process\Process;

final class DockerService
{
    /**
     * @return array{status: string, image: string, version: string, startedAt: string}
     */
    public function getContainerStatus(string $containerName): array
    {
        $process = new Process([
            'docker', 'inspect',
            '--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}',
            $containerName,
        ]);
        $process->setTimeout(10);
        $process->run();

        if (!$process->isSuccessful()) {
            return [
                'status' => 'not_found',
                'image' => '',
                'version' => '',
                'startedAt' => '',
            ];
        }

        $parts = explode('||', trim($process->getOutput()));

        if (\count($parts) < 3) {
            return [
                'status' => 'not_found',
                'image' => '',
                'version' => '',
                'startedAt' => '',
            ];
        }

        $image = $parts[1];
        $version = 'latest';
        if (str_contains($image, ':')) {
            $version = substr($image, strrpos($image, ':') + 1);
        }

        return [
            'status' => $parts[0],
            'image' => $image,
            'version' => $version,
            'startedAt' => $parts[2],
        ];
    }

    /**
     * @return array{cpuPercent: float, memoryUsage: string, memoryLimit: string, memoryPercent: float}
     */
    public function getContainerStats(string $containerName): array
    {
        $process = new Process([
            'docker', 'stats', '--no-stream',
            '--format', '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}',
            $containerName,
        ]);
        $process->setTimeout(10);
        $process->run();

        if (!$process->isSuccessful()) {
            return [
                'cpuPercent' => 0.0,
                'memoryUsage' => '',
                'memoryLimit' => '',
                'memoryPercent' => 0.0,
            ];
        }

        $parts = explode('||', trim($process->getOutput()));

        if (\count($parts) < 3) {
            return [
                'cpuPercent' => 0.0,
                'memoryUsage' => '',
                'memoryLimit' => '',
                'memoryPercent' => 0.0,
            ];
        }

        $memParts = explode(' / ', $parts[1]);

        return [
            'cpuPercent' => (float) rtrim($parts[0], '%'),
            'memoryUsage' => $memParts[0] ?? '',
            'memoryLimit' => $memParts[1] ?? '',
            'memoryPercent' => (float) rtrim($parts[2], '%'),
        ];
    }
}
  • Step 2: Commit
git add src/Service/DockerService.php
git commit -m "feat : add DockerService for container status and stats"

Task 2: API Platform DTOs

Files:

  • Create: src/ApiResource/Dashboard.php

  • Create: src/ApiResource/EnvironmentHealth.php

  • Step 1: Create Dashboard DTO

<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\DashboardProvider;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/dashboard',
            security: "is_granted('ROLE_ADMIN')",
            provider: DashboardProvider::class,
        ),
    ],
)]
final class Dashboard
{
    /** @var list<array{name: string, slug: string, giteaUrl: ?string, environments: list<array{id: int, name: string, status: string, version: string}>}> */
    public array $applications = [];
}
  • Step 2: Create EnvironmentHealth DTO
<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\EnvironmentHealthProvider;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/environments/{id}/health',
            security: "is_granted('ROLE_ADMIN')",
            provider: EnvironmentHealthProvider::class,
        ),
    ],
)]
final class EnvironmentHealth
{
    public string $status = 'not_found';
    public string $version = '';
    public string $startedAt = '';
    public float $cpuPercent = 0.0;
    public string $memoryUsage = '';
    public string $memoryLimit = '';
    public float $memoryPercent = 0.0;
}
  • Step 3: Commit
git add src/ApiResource/Dashboard.php src/ApiResource/EnvironmentHealth.php
git commit -m "feat : add Dashboard and EnvironmentHealth API Platform DTOs"

Task 3: DashboardProvider

Files:

  • Create: src/State/DashboardProvider.php

  • Step 1: Create the provider

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\Dashboard;
use App\Repository\ApplicationRepository;
use App\Service\DockerService;

final readonly class DashboardProvider implements ProviderInterface
{
    public function __construct(
        private ApplicationRepository $applicationRepository,
        private DockerService $dockerService,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): Dashboard
    {
        $applications = $this->applicationRepository->findAll();

        $dto = new Dashboard();

        foreach ($applications as $app) {
            $envs = [];

            foreach ($app->getEnvironments() as $env) {
                $containerStatus = $this->dockerService->getContainerStatus($env->getContainerName());

                $envs[] = [
                    'id' => $env->getId(),
                    'name' => $env->getName(),
                    'status' => $containerStatus['status'],
                    'version' => $containerStatus['version'],
                ];
            }

            $dto->applications[] = [
                'name' => $app->getName(),
                'slug' => $app->getSlug(),
                'giteaUrl' => $app->getGiteaUrl(),
                'environments' => $envs,
            ];
        }

        return $dto;
    }
}
  • Step 2: Commit
git add src/State/DashboardProvider.php
git commit -m "feat : add DashboardProvider for container status overview"

Task 4: EnvironmentHealthProvider

Files:

  • Create: src/State/EnvironmentHealthProvider.php

  • Step 1: Create the provider

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EnvironmentHealth;
use App\Repository\EnvironmentRepository;
use App\Service\DockerService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final readonly class EnvironmentHealthProvider implements ProviderInterface
{
    public function __construct(
        private EnvironmentRepository $environmentRepository,
        private DockerService $dockerService,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): EnvironmentHealth
    {
        $id = $uriVariables['id'] ?? null;
        $environment = $id ? $this->environmentRepository->find($id) : null;

        if (null === $environment) {
            throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
        }

        $containerName = $environment->getContainerName();
        $status = $this->dockerService->getContainerStatus($containerName);
        $stats = $this->dockerService->getContainerStats($containerName);

        $dto = new EnvironmentHealth();
        $dto->status = $status['status'];
        $dto->version = $status['version'];
        $dto->startedAt = $status['startedAt'];
        $dto->cpuPercent = $stats['cpuPercent'];
        $dto->memoryUsage = $stats['memoryUsage'];
        $dto->memoryLimit = $stats['memoryLimit'];
        $dto->memoryPercent = $stats['memoryPercent'];

        return $dto;
    }
}
  • Step 2: Commit
git add src/State/EnvironmentHealthProvider.php
git commit -m "feat : add EnvironmentHealthProvider for detailed env metrics"

Task 5: Frontend types and service

Files:

  • Create: frontend/services/dto/dashboard.ts

  • Create: frontend/services/dashboard.ts

  • Step 1: Create dashboard types

type DashboardEnvironment = {
    id: number
    name: string
    status: string
    version: string
}

type DashboardApplication = {
    name: string
    slug: string
    giteaUrl?: string
    environments: DashboardEnvironment[]
}

type DashboardResponse = {
    applications: DashboardApplication[]
}

type EnvironmentHealth = {
    status: string
    version: string
    startedAt: string
    cpuPercent: number
    memoryUsage: string
    memoryLimit: string
    memoryPercent: number
}
  • Step 2: Create dashboard service
import type { DashboardResponse, EnvironmentHealth } from './dto/dashboard'

export function getDashboard(): Promise<DashboardResponse> {
    return useApi().get<DashboardResponse>('/dashboard', undefined, {
        toast: false,
    })
}

export function getEnvironmentHealth(envId: number): Promise<EnvironmentHealth> {
    return useApi().get<EnvironmentHealth>(`/environments/${envId}/health`, undefined, {
        toast: false,
    })
}
  • Step 3: Commit
git add frontend/services/dto/dashboard.ts frontend/services/dashboard.ts
git commit -m "feat : add frontend dashboard types and service"

Task 6: i18n translations

Files:

  • Modify: frontend/i18n/locales/fr.json

  • Step 1: Add dashboard translations

Add a new dashboard section (replace the existing one which only has title):

{
    "dashboard": {
        "title": "Dashboard",
        "description": "Vue d'ensemble du SI",
        "refresh": "Actualiser",
        "status": {
            "running": "En ligne",
            "exited": "Arrete",
            "restarting": "Redemarrage",
            "not_found": "Introuvable"
        }
    }
}

Add a health sub-object inside the environments section:

{
    "environments": {
        "...existing keys...",
        "health": {
            "title": "Sante du container",
            "status": "Statut",
            "version": "Version",
            "uptime": "Uptime",
            "cpu": "CPU",
            "memory": "Memoire",
            "noData": "Aucune donnee disponible"
        }
    }
}
  • Step 2: Commit
git add frontend/i18n/locales/fr.json
git commit -m "feat : add i18n translations for dashboard and health"

Task 7: Dashboard page

Files:

  • Create: frontend/pages/dashboard.vue

  • Step 1: Create the dashboard page

<script setup lang="ts">
import type { DashboardResponse } from '~/services/dto/dashboard'
import { getDashboard } from '~/services/dashboard'

const { t } = useI18n()

const data = ref<DashboardResponse | null>(null)
const loading = ref(true)

async function loadDashboard() {
    loading.value = true
    try {
        data.value = await getDashboard()
    } finally {
        loading.value = false
    }
}

function statusClass(status: string): string {
    switch (status) {
        case 'running': return 'bg-green-100 text-green-700'
        case 'exited': return 'bg-red-100 text-red-700'
        case 'restarting': return 'bg-orange-100 text-orange-700'
        default: return 'bg-neutral-100 text-neutral-500'
    }
}

function statusLabel(status: string): string {
    const key = `dashboard.status.${status}`
    return t(key)
}

onMounted(loadDashboard)
</script>

<template>
    <div class="px-4 py-8 sm:px-8 lg:px-16">
        <div class="flex items-center justify-between pb-6">
            <h1 class="text-2xl font-bold text-primary-500 sm:text-4xl">{{ t('dashboard.title') }}</h1>
            <MalioButton
                :label="t('dashboard.refresh')"
                variant="secondary"
                icon-name="mdi:refresh"
                icon-position="left"
                :loading="loading"
                @click="loadDashboard"
            />
        </div>

        <!-- Loading -->
        <div v-if="loading && !data" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
            <div v-for="i in 3" :key="i" class="rounded-lg bg-tertiary-500 p-5 animate-pulse">
                <div class="h-6 bg-neutral-300 rounded w-1/3 mb-4" />
                <div class="h-4 bg-neutral-300 rounded w-2/3 mb-2" />
                <div class="h-4 bg-neutral-300 rounded w-1/2" />
            </div>
        </div>

        <!-- Dashboard cards -->
        <div v-else-if="data" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
            <NuxtLink
                v-for="app in data.applications"
                :key="app.slug"
                :to="`/applications/${app.slug}`"
                class="rounded-lg bg-tertiary-500 p-5 transition hover:shadow-md"
            >
                <div class="flex items-center justify-between mb-4">
                    <h3 class="text-lg font-semibold text-neutral-900">{{ app.name }}</h3>
                    <a
                        v-if="app.giteaUrl"
                        :href="app.giteaUrl"
                        target="_blank"
                        class="text-neutral-400 hover:text-primary-500"
                        @click.stop
                    >
                        <Icon name="mdi:open-in-new" size="18" />
                    </a>
                </div>

                <div v-for="env in app.environments" :key="env.id" class="flex items-center justify-between py-2 border-t border-neutral-200 first:border-t-0">
                    <span class="text-sm text-neutral-700">{{ env.name }}</span>
                    <div class="flex items-center gap-3">
                        <span class="text-xs font-mono text-neutral-400">{{ env.version }}</span>
                        <span
                            class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold"
                            :class="statusClass(env.status)"
                        >
                            {{ statusLabel(env.status) }}
                        </span>
                    </div>
                </div>

                <div v-if="!app.environments.length" class="text-sm text-neutral-400">
                    {{ t('applications.card.noEnvironments') }}
                </div>
            </NuxtLink>
        </div>
    </div>
</template>
  • Step 2: Commit
git add frontend/pages/dashboard.vue
git commit -m "feat : add dashboard page with container status overview"

Files:

  • Modify: frontend/layouts/default.vue

  • Modify: frontend/middleware/auth.global.ts

  • Step 1: Add Dashboard link to sidebar

In frontend/layouts/default.vue, find the existing SidebarLink for applications (line ~41) and add the Dashboard link BEFORE it:

                    <SidebarLink
                        to="/dashboard"
                        icon="mdi:view-dashboard"
                        :label="$t('dashboard.title')"
                        :collapsed="sidebarIsCollapsed"
                        :class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
                        @click="ui.closeMobileSidebar()"
                    />
                    <SidebarLink
                        to="/applications"
                        icon="mdi:apps"
                        :label="$t('applications.title')"
                        :collapsed="sidebarIsCollapsed"
                        @click="ui.closeMobileSidebar()"
                    />

Note: move the border-t class from the applications link to the dashboard link (first item gets the border).

  • Step 2: Update auth middleware redirect

In frontend/middleware/auth.global.ts, change the redirect from /applications to /dashboard:

Change:

if (auth.isAuthenticated && (isLogin || to.path === '/')) {
    return navigateTo('/applications')
}

To:

if (auth.isAuthenticated && (isLogin || to.path === '/')) {
    return navigateTo('/dashboard')
}
  • Step 3: Commit
git add frontend/layouts/default.vue frontend/middleware/auth.global.ts
git commit -m "feat : add Dashboard to sidebar and redirect / to /dashboard"

Task 9: Health metrics on detail page

Files:

  • Modify: frontend/pages/applications/[slug].vue

  • Step 1: Add imports and state

At the top of <script setup>, add the import after existing ones:

import type { EnvironmentHealth } from '~/services/dto/dashboard'
import { getEnvironmentHealth } from '~/services/dashboard'

Add state after the deploy-related refs:

// Health data per env
const healthByEnvId = ref<Record<number, EnvironmentHealth>>({})
const loadingHealth = ref(false)
  • Step 2: Add health loading function

Add after the deploy functions:

async function loadHealthData() {
    if (!application.value?.environments?.length) return
    loadingHealth.value = true
    try {
        const promises = application.value.environments.map(async (env) => {
            try {
                const health = await getEnvironmentHealth(env.id!)
                healthByEnvId.value[env.id!] = health
            } catch {
                // silently ignore individual env health failures
            }
        })
        await Promise.all(promises)
    } finally {
        loadingHealth.value = false
    }
}

Update loadApplication to also load health after the app data:

async function loadApplication() {
    loading.value = true
    try {
        application.value = await getApplication(slug)
    } finally {
        loading.value = false
    }
    loadHealthData()
}
  • Step 3: Add helper function for uptime
function formatUptime(startedAt: string): string {
    if (!startedAt) return '-'
    const start = new Date(startedAt)
    const now = new Date()
    const diffMs = now.getTime() - start.getTime()
    const days = Math.floor(diffMs / 86400000)
    const hours = Math.floor((diffMs % 86400000) / 3600000)
    const minutes = Math.floor((diffMs % 3600000) / 60000)
    if (days > 0) return `${days}j ${hours}h`
    if (hours > 0) return `${hours}h ${minutes}m`
    return `${minutes}m`
}

function statusClass(status: string): string {
    switch (status) {
        case 'running': return 'bg-green-100 text-green-700'
        case 'exited': return 'bg-red-100 text-red-700'
        case 'restarting': return 'bg-orange-100 text-orange-700'
        default: return 'bg-neutral-100 text-neutral-500'
    }
}
  • Step 4: Add health block to environment cards

In the template, inside each environment card (<div v-for="env in application.environments" ...>), add a health metrics block AFTER the log files section and BEFORE the edit/delete buttons div:

                    <!-- Health metrics -->
                    <div v-if="healthByEnvId[env.id!]" class="mt-4 border-t border-neutral-200 pt-3">
                        <p class="text-xs font-semibold uppercase tracking-wider text-neutral-400 mb-3">{{ t('environments.health.title') }}</p>
                        <div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
                            <div>
                                <p class="text-xs text-neutral-400">{{ t('environments.health.status') }}</p>
                                <span
                                    class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
                                    :class="statusClass(healthByEnvId[env.id!].status)"
                                >
                                    {{ t(`dashboard.status.${healthByEnvId[env.id!].status}`) }}
                                </span>
                            </div>
                            <div>
                                <p class="text-xs text-neutral-400">{{ t('environments.health.version') }}</p>
                                <p class="text-sm font-mono text-neutral-800 mt-1">{{ healthByEnvId[env.id!].version || '-' }}</p>
                            </div>
                            <div>
                                <p class="text-xs text-neutral-400">{{ t('environments.health.uptime') }}</p>
                                <p class="text-sm text-neutral-800 mt-1">{{ formatUptime(healthByEnvId[env.id!].startedAt) }}</p>
                            </div>
                            <div>
                                <p class="text-xs text-neutral-400">{{ t('environments.health.cpu') }}</p>
                                <p class="text-sm text-neutral-800 mt-1">{{ healthByEnvId[env.id!].cpuPercent }}%</p>
                            </div>
                            <div class="col-span-2">
                                <p class="text-xs text-neutral-400">{{ t('environments.health.memory') }}</p>
                                <p class="text-sm text-neutral-800 mt-1">
                                    {{ healthByEnvId[env.id!].memoryUsage }} / {{ healthByEnvId[env.id!].memoryLimit }}
                                    <span class="text-neutral-400">({{ healthByEnvId[env.id!].memoryPercent }}%)</span>
                                </p>
                            </div>
                        </div>
                    </div>
  • Step 5: Commit
git add frontend/pages/applications/[slug].vue
git commit -m "feat : add health metrics block on environment detail"

Task 10: Build and verify

  • Step 1: Clear Symfony cache
docker exec -t -u www-data php-central-fpm php bin/console cache:clear
  • Step 2: Build frontend
make build-nuxtJS
  • Step 3: Verify API endpoints
# Login
docker exec -t php-central-fpm curl -s -c /tmp/cookies -X POST http://nginx/api/login_check \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin"}'

# Dashboard endpoint
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/dashboard

# Environment health endpoint (use actual env id)
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/environments/9/health
  • Step 4: Final commit if adjustments needed
git add -A
git commit -m "fix : adjustments from end-to-end testing"