docs : plan d'implementation phase 2b dashboard sante
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
837
docs/superpowers/plans/2026-04-06-phase2b-dashboard-sante.md
Normal file
837
docs/superpowers/plans/2026-04-06-phase2b-dashboard-sante.md
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
# Phase 2b — Dashboard Sante Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a dashboard showing container health status (running/stopped) and version for all environments, plus detailed metrics (CPU, memory, uptime) on the application detail page.
|
||||||
|
|
||||||
|
**Architecture:** A DockerService executes Docker CLI commands via Process to get container status and stats. Two new API Platform endpoints expose dashboard overview and per-environment health. Frontend adds a new dashboard page and enriches the detail page.
|
||||||
|
|
||||||
|
**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Symfony Process, Docker CLI, Nuxt 4, Vue 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Backend — Create
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|---------------|
|
||||||
|
| `src/Service/DockerService.php` | Execute Docker CLI commands via Process |
|
||||||
|
| `src/ApiResource/Dashboard.php` | API Platform DTO for dashboard response |
|
||||||
|
| `src/ApiResource/EnvironmentHealth.php` | API Platform DTO for env health response |
|
||||||
|
| `src/State/DashboardProvider.php` | Provider that aggregates all apps + container status |
|
||||||
|
| `src/State/EnvironmentHealthProvider.php` | Provider for single env detailed health |
|
||||||
|
|
||||||
|
### Frontend — Create
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|---------------|
|
||||||
|
| `frontend/services/dashboard.ts` | API calls for dashboard and env health |
|
||||||
|
| `frontend/services/dto/dashboard.ts` | TypeScript types |
|
||||||
|
| `frontend/pages/dashboard.vue` | Dashboard page |
|
||||||
|
|
||||||
|
### Frontend — Modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `frontend/pages/applications/[slug].vue` | Add health metrics block per env |
|
||||||
|
| `frontend/layouts/default.vue` | Add Dashboard sidebar link |
|
||||||
|
| `frontend/middleware/auth.global.ts` | Redirect `/` to `/dashboard` instead of `/applications` |
|
||||||
|
| `frontend/i18n/locales/fr.json` | Dashboard translation keys |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: DockerService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/DockerService.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the service**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
final class DockerService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{status: string, image: string, version: string, startedAt: string}
|
||||||
|
*/
|
||||||
|
public function getContainerStatus(string $containerName): array
|
||||||
|
{
|
||||||
|
$process = new Process([
|
||||||
|
'docker', 'inspect',
|
||||||
|
'--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}',
|
||||||
|
$containerName,
|
||||||
|
]);
|
||||||
|
$process->setTimeout(10);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
return [
|
||||||
|
'status' => 'not_found',
|
||||||
|
'image' => '',
|
||||||
|
'version' => '',
|
||||||
|
'startedAt' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('||', trim($process->getOutput()));
|
||||||
|
|
||||||
|
if (\count($parts) < 3) {
|
||||||
|
return [
|
||||||
|
'status' => 'not_found',
|
||||||
|
'image' => '',
|
||||||
|
'version' => '',
|
||||||
|
'startedAt' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = $parts[1];
|
||||||
|
$version = 'latest';
|
||||||
|
if (str_contains($image, ':')) {
|
||||||
|
$version = substr($image, strrpos($image, ':') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $parts[0],
|
||||||
|
'image' => $image,
|
||||||
|
'version' => $version,
|
||||||
|
'startedAt' => $parts[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{cpuPercent: float, memoryUsage: string, memoryLimit: string, memoryPercent: float}
|
||||||
|
*/
|
||||||
|
public function getContainerStats(string $containerName): array
|
||||||
|
{
|
||||||
|
$process = new Process([
|
||||||
|
'docker', 'stats', '--no-stream',
|
||||||
|
'--format', '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}',
|
||||||
|
$containerName,
|
||||||
|
]);
|
||||||
|
$process->setTimeout(10);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
return [
|
||||||
|
'cpuPercent' => 0.0,
|
||||||
|
'memoryUsage' => '',
|
||||||
|
'memoryLimit' => '',
|
||||||
|
'memoryPercent' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('||', trim($process->getOutput()));
|
||||||
|
|
||||||
|
if (\count($parts) < 3) {
|
||||||
|
return [
|
||||||
|
'cpuPercent' => 0.0,
|
||||||
|
'memoryUsage' => '',
|
||||||
|
'memoryLimit' => '',
|
||||||
|
'memoryPercent' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$memParts = explode(' / ', $parts[1]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cpuPercent' => (float) rtrim($parts[0], '%'),
|
||||||
|
'memoryUsage' => $memParts[0] ?? '',
|
||||||
|
'memoryLimit' => $memParts[1] ?? '',
|
||||||
|
'memoryPercent' => (float) rtrim($parts[2], '%'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/DockerService.php
|
||||||
|
git commit -m "feat : add DockerService for container status and stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: API Platform DTOs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ApiResource/Dashboard.php`
|
||||||
|
- Create: `src/ApiResource/EnvironmentHealth.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create Dashboard DTO**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\DashboardProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/dashboard',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: DashboardProvider::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class Dashboard
|
||||||
|
{
|
||||||
|
/** @var list<array{name: string, slug: string, giteaUrl: ?string, environments: list<array{id: int, name: string, status: string, version: string}>}> */
|
||||||
|
public array $applications = [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create EnvironmentHealth DTO**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\EnvironmentHealthProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/environments/{id}/health',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EnvironmentHealthProvider::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class EnvironmentHealth
|
||||||
|
{
|
||||||
|
public string $status = 'not_found';
|
||||||
|
public string $version = '';
|
||||||
|
public string $startedAt = '';
|
||||||
|
public float $cpuPercent = 0.0;
|
||||||
|
public string $memoryUsage = '';
|
||||||
|
public string $memoryLimit = '';
|
||||||
|
public float $memoryPercent = 0.0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ApiResource/Dashboard.php src/ApiResource/EnvironmentHealth.php
|
||||||
|
git commit -m "feat : add Dashboard and EnvironmentHealth API Platform DTOs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: DashboardProvider
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/State/DashboardProvider.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the provider**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\Dashboard;
|
||||||
|
use App\Repository\ApplicationRepository;
|
||||||
|
use App\Service\DockerService;
|
||||||
|
|
||||||
|
final readonly class DashboardProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ApplicationRepository $applicationRepository,
|
||||||
|
private DockerService $dockerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Dashboard
|
||||||
|
{
|
||||||
|
$applications = $this->applicationRepository->findAll();
|
||||||
|
|
||||||
|
$dto = new Dashboard();
|
||||||
|
|
||||||
|
foreach ($applications as $app) {
|
||||||
|
$envs = [];
|
||||||
|
|
||||||
|
foreach ($app->getEnvironments() as $env) {
|
||||||
|
$containerStatus = $this->dockerService->getContainerStatus($env->getContainerName());
|
||||||
|
|
||||||
|
$envs[] = [
|
||||||
|
'id' => $env->getId(),
|
||||||
|
'name' => $env->getName(),
|
||||||
|
'status' => $containerStatus['status'],
|
||||||
|
'version' => $containerStatus['version'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dto->applications[] = [
|
||||||
|
'name' => $app->getName(),
|
||||||
|
'slug' => $app->getSlug(),
|
||||||
|
'giteaUrl' => $app->getGiteaUrl(),
|
||||||
|
'environments' => $envs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/State/DashboardProvider.php
|
||||||
|
git commit -m "feat : add DashboardProvider for container status overview"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: EnvironmentHealthProvider
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/State/EnvironmentHealthProvider.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the provider**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\EnvironmentHealth;
|
||||||
|
use App\Repository\EnvironmentRepository;
|
||||||
|
use App\Service\DockerService;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final readonly class EnvironmentHealthProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EnvironmentRepository $environmentRepository,
|
||||||
|
private DockerService $dockerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EnvironmentHealth
|
||||||
|
{
|
||||||
|
$id = $uriVariables['id'] ?? null;
|
||||||
|
$environment = $id ? $this->environmentRepository->find($id) : null;
|
||||||
|
|
||||||
|
if (null === $environment) {
|
||||||
|
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerName = $environment->getContainerName();
|
||||||
|
$status = $this->dockerService->getContainerStatus($containerName);
|
||||||
|
$stats = $this->dockerService->getContainerStats($containerName);
|
||||||
|
|
||||||
|
$dto = new EnvironmentHealth();
|
||||||
|
$dto->status = $status['status'];
|
||||||
|
$dto->version = $status['version'];
|
||||||
|
$dto->startedAt = $status['startedAt'];
|
||||||
|
$dto->cpuPercent = $stats['cpuPercent'];
|
||||||
|
$dto->memoryUsage = $stats['memoryUsage'];
|
||||||
|
$dto->memoryLimit = $stats['memoryLimit'];
|
||||||
|
$dto->memoryPercent = $stats['memoryPercent'];
|
||||||
|
|
||||||
|
return $dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/State/EnvironmentHealthProvider.php
|
||||||
|
git commit -m "feat : add EnvironmentHealthProvider for detailed env metrics"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend types and service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/services/dto/dashboard.ts`
|
||||||
|
- Create: `frontend/services/dashboard.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create dashboard types**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type DashboardEnvironment = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardApplication = {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
giteaUrl?: string
|
||||||
|
environments: DashboardEnvironment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardResponse = {
|
||||||
|
applications: DashboardApplication[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvironmentHealth = {
|
||||||
|
status: string
|
||||||
|
version: string
|
||||||
|
startedAt: string
|
||||||
|
cpuPercent: number
|
||||||
|
memoryUsage: string
|
||||||
|
memoryLimit: string
|
||||||
|
memoryPercent: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create dashboard service**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { DashboardResponse, EnvironmentHealth } from './dto/dashboard'
|
||||||
|
|
||||||
|
export function getDashboard(): Promise<DashboardResponse> {
|
||||||
|
return useApi().get<DashboardResponse>('/dashboard', undefined, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvironmentHealth(envId: number): Promise<EnvironmentHealth> {
|
||||||
|
return useApi().get<EnvironmentHealth>(`/environments/${envId}/health`, undefined, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/services/dto/dashboard.ts frontend/services/dashboard.ts
|
||||||
|
git commit -m "feat : add frontend dashboard types and service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: i18n translations
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/i18n/locales/fr.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add dashboard translations**
|
||||||
|
|
||||||
|
Add a new `dashboard` section (replace the existing one which only has `title`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"description": "Vue d'ensemble du SI",
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"status": {
|
||||||
|
"running": "En ligne",
|
||||||
|
"exited": "Arrete",
|
||||||
|
"restarting": "Redemarrage",
|
||||||
|
"not_found": "Introuvable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `health` sub-object inside the `environments` section:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"environments": {
|
||||||
|
"...existing keys...",
|
||||||
|
"health": {
|
||||||
|
"title": "Sante du container",
|
||||||
|
"status": "Statut",
|
||||||
|
"version": "Version",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Memoire",
|
||||||
|
"noData": "Aucune donnee disponible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/i18n/locales/fr.json
|
||||||
|
git commit -m "feat : add i18n translations for dashboard and health"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Dashboard page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/pages/dashboard.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the dashboard page**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardResponse } from '~/services/dto/dashboard'
|
||||||
|
import { getDashboard } from '~/services/dashboard'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const data = ref<DashboardResponse | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
data.value = await getDashboard()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'bg-green-100 text-green-700'
|
||||||
|
case 'exited': return 'bg-red-100 text-red-700'
|
||||||
|
case 'restarting': return 'bg-orange-100 text-orange-700'
|
||||||
|
default: return 'bg-neutral-100 text-neutral-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const key = `dashboard.status.${status}`
|
||||||
|
return t(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDashboard)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="px-4 py-8 sm:px-8 lg:px-16">
|
||||||
|
<div class="flex items-center justify-between pb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500 sm:text-4xl">{{ t('dashboard.title') }}</h1>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('dashboard.refresh')"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:refresh"
|
||||||
|
icon-position="left"
|
||||||
|
:loading="loading"
|
||||||
|
@click="loadDashboard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading && !data" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div v-for="i in 3" :key="i" class="rounded-lg bg-tertiary-500 p-5 animate-pulse">
|
||||||
|
<div class="h-6 bg-neutral-300 rounded w-1/3 mb-4" />
|
||||||
|
<div class="h-4 bg-neutral-300 rounded w-2/3 mb-2" />
|
||||||
|
<div class="h-4 bg-neutral-300 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard cards -->
|
||||||
|
<div v-else-if="data" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="app in data.applications"
|
||||||
|
:key="app.slug"
|
||||||
|
:to="`/applications/${app.slug}`"
|
||||||
|
class="rounded-lg bg-tertiary-500 p-5 transition hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-neutral-900">{{ app.name }}</h3>
|
||||||
|
<a
|
||||||
|
v-if="app.giteaUrl"
|
||||||
|
:href="app.giteaUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="text-neutral-400 hover:text-primary-500"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<Icon name="mdi:open-in-new" size="18" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="env in app.environments" :key="env.id" class="flex items-center justify-between py-2 border-t border-neutral-200 first:border-t-0">
|
||||||
|
<span class="text-sm text-neutral-700">{{ env.name }}</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs font-mono text-neutral-400">{{ env.version }}</span>
|
||||||
|
<span
|
||||||
|
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||||
|
:class="statusClass(env.status)"
|
||||||
|
>
|
||||||
|
{{ statusLabel(env.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!app.environments.length" class="text-sm text-neutral-400">
|
||||||
|
{{ t('applications.card.noEnvironments') }}
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/pages/dashboard.vue
|
||||||
|
git commit -m "feat : add dashboard page with container status overview"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Sidebar link + redirect
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/layouts/default.vue`
|
||||||
|
- Modify: `frontend/middleware/auth.global.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Dashboard link to sidebar**
|
||||||
|
|
||||||
|
In `frontend/layouts/default.vue`, find the existing `SidebarLink` for applications (line ~41) and add the Dashboard link BEFORE it:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<SidebarLink
|
||||||
|
to="/dashboard"
|
||||||
|
icon="mdi:view-dashboard"
|
||||||
|
:label="$t('dashboard.title')"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
to="/applications"
|
||||||
|
icon="mdi:apps"
|
||||||
|
:label="$t('applications.title')"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: move the `border-t` class from the applications link to the dashboard link (first item gets the border).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update auth middleware redirect**
|
||||||
|
|
||||||
|
In `frontend/middleware/auth.global.ts`, change the redirect from `/applications` to `/dashboard`:
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```typescript
|
||||||
|
if (auth.isAuthenticated && (isLogin || to.path === '/')) {
|
||||||
|
return navigateTo('/applications')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```typescript
|
||||||
|
if (auth.isAuthenticated && (isLogin || to.path === '/')) {
|
||||||
|
return navigateTo('/dashboard')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/layouts/default.vue frontend/middleware/auth.global.ts
|
||||||
|
git commit -m "feat : add Dashboard to sidebar and redirect / to /dashboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Health metrics on detail page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/pages/applications/[slug].vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
At the top of `<script setup>`, add the import after existing ones:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { EnvironmentHealth } from '~/services/dto/dashboard'
|
||||||
|
import { getEnvironmentHealth } from '~/services/dashboard'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add state after the deploy-related refs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Health data per env
|
||||||
|
const healthByEnvId = ref<Record<number, EnvironmentHealth>>({})
|
||||||
|
const loadingHealth = ref(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add health loading function**
|
||||||
|
|
||||||
|
Add after the deploy functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function loadHealthData() {
|
||||||
|
if (!application.value?.environments?.length) return
|
||||||
|
loadingHealth.value = true
|
||||||
|
try {
|
||||||
|
const promises = application.value.environments.map(async (env) => {
|
||||||
|
try {
|
||||||
|
const health = await getEnvironmentHealth(env.id!)
|
||||||
|
healthByEnvId.value[env.id!] = health
|
||||||
|
} catch {
|
||||||
|
// silently ignore individual env health failures
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(promises)
|
||||||
|
} finally {
|
||||||
|
loadingHealth.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `loadApplication` to also load health after the app data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function loadApplication() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
application.value = await getApplication(slug)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
loadHealthData()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add helper function for uptime**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function formatUptime(startedAt: string): string {
|
||||||
|
if (!startedAt) return '-'
|
||||||
|
const start = new Date(startedAt)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - start.getTime()
|
||||||
|
const days = Math.floor(diffMs / 86400000)
|
||||||
|
const hours = Math.floor((diffMs % 86400000) / 3600000)
|
||||||
|
const minutes = Math.floor((diffMs % 3600000) / 60000)
|
||||||
|
if (days > 0) return `${days}j ${hours}h`
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`
|
||||||
|
return `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'bg-green-100 text-green-700'
|
||||||
|
case 'exited': return 'bg-red-100 text-red-700'
|
||||||
|
case 'restarting': return 'bg-orange-100 text-orange-700'
|
||||||
|
default: return 'bg-neutral-100 text-neutral-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add health block to environment cards**
|
||||||
|
|
||||||
|
In the template, inside each environment card (`<div v-for="env in application.environments" ...>`), add a health metrics block AFTER the log files section and BEFORE the edit/delete buttons div:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Health metrics -->
|
||||||
|
<div v-if="healthByEnvId[env.id!]" class="mt-4 border-t border-neutral-200 pt-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-neutral-400 mb-3">{{ t('environments.health.title') }}</p>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-neutral-400">{{ t('environments.health.status') }}</p>
|
||||||
|
<span
|
||||||
|
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
|
||||||
|
:class="statusClass(healthByEnvId[env.id!].status)"
|
||||||
|
>
|
||||||
|
{{ t(`dashboard.status.${healthByEnvId[env.id!].status}`) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-neutral-400">{{ t('environments.health.version') }}</p>
|
||||||
|
<p class="text-sm font-mono text-neutral-800 mt-1">{{ healthByEnvId[env.id!].version || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-neutral-400">{{ t('environments.health.uptime') }}</p>
|
||||||
|
<p class="text-sm text-neutral-800 mt-1">{{ formatUptime(healthByEnvId[env.id!].startedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-neutral-400">{{ t('environments.health.cpu') }}</p>
|
||||||
|
<p class="text-sm text-neutral-800 mt-1">{{ healthByEnvId[env.id!].cpuPercent }}%</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<p class="text-xs text-neutral-400">{{ t('environments.health.memory') }}</p>
|
||||||
|
<p class="text-sm text-neutral-800 mt-1">
|
||||||
|
{{ healthByEnvId[env.id!].memoryUsage }} / {{ healthByEnvId[env.id!].memoryLimit }}
|
||||||
|
<span class="text-neutral-400">({{ healthByEnvId[env.id!].memoryPercent }}%)</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/pages/applications/[slug].vue
|
||||||
|
git commit -m "feat : add health metrics block on environment detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Build and verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Clear Symfony cache**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t -u www-data php-central-fpm php bin/console cache:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-nuxtJS
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify API endpoints**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
docker exec -t php-central-fpm curl -s -c /tmp/cookies -X POST http://nginx/api/login_check \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin"}'
|
||||||
|
|
||||||
|
# Dashboard endpoint
|
||||||
|
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/dashboard
|
||||||
|
|
||||||
|
# Environment health endpoint (use actual env id)
|
||||||
|
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/environments/9/health
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final commit if adjustments needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix : adjustments from end-to-end testing"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user