feat(system): page Status avec endpoint API Platform et composants Vue

- Nouveau domaine System/Domain/Model/SystemStatus (value object)
- QueryHandler agrégeant métriques mangas, chapitres, jobs (global/24h/7j), stockage et sources
- Endpoint GET /api/system/status via API Platform (singleton)
- Calcul de l'espace disque par RecursiveDirectoryIterator sur public/images
- Page Vue /system/status avec 6 cards (Mangas, Chapitres, Jobs, Stockage, Sources, Système)
- Nettoyage du router : suppression des PlaceholderComponent et routes placeholder
- Sidebar : suppression des entrées sans page réelle
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-17 22:04:48 +01:00
parent c2b55e9018
commit ca8791cc0d
21 changed files with 825 additions and 61 deletions

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domain\System\Application\Query;
final class GetSystemStatusQuery
{
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Domain\System\Application\QueryHandler;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\System\Application\Query\GetSystemStatusQuery;
use App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface;
use App\Domain\System\Domain\Model\SystemStatus;
readonly class GetSystemStatusQueryHandler
{
public function __construct(
private SystemStatusRepositoryInterface $systemStatusRepository,
private JobRepositoryInterface $jobRepository,
private string $mangaDataPath,
private string $imagesStoragePath,
) {
}
public function handle(GetSystemStatusQuery $query): SystemStatus
{
$now = new \DateTimeImmutable();
$last24h = $now->modify('-24 hours');
$last7d = $now->modify('-7 days');
$totalJobs = $this->jobRepository->countByCriteria([]);
$completedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED]);
$failedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED]);
$pendingJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::PENDING]);
$inProgressJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::IN_PROGRESS]);
$totalJobsLast24h = $this->jobRepository->countByCriteria(['createdAfter' => $last24h]);
$completedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last24h]);
$failedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last24h]);
$totalJobsLast7d = $this->jobRepository->countByCriteria(['createdAfter' => $last7d]);
$completedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last7d]);
$failedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last7d]);
$storagePath = $this->imagesStoragePath;
$storageTotalBytes = (int) (@disk_total_space($storagePath) ?: 0);
$storageFreeBytes = (int) (@disk_free_space($storagePath) ?: 0);
$storageUsedBytes = $this->computeDirectorySize($storagePath);
return new SystemStatus(
totalMangas: $this->systemStatusRepository->countMangas(),
monitoredMangas: $this->systemStatusRepository->countMonitoredMangas(),
mangasByStatus: $this->systemStatusRepository->countMangasByStatus(),
totalChapters: $this->systemStatusRepository->countChapters(),
downloadedChapters: $this->systemStatusRepository->countDownloadedChapters(),
totalJobs: $totalJobs,
completedJobs: $completedJobs,
failedJobs: $failedJobs,
pendingJobs: $pendingJobs,
inProgressJobs: $inProgressJobs,
totalJobsLast24h: $totalJobsLast24h,
completedJobsLast24h: $completedJobsLast24h,
failedJobsLast24h: $failedJobsLast24h,
totalJobsLast7d: $totalJobsLast7d,
completedJobsLast7d: $completedJobsLast7d,
failedJobsLast7d: $failedJobsLast7d,
storagePath: $this->mangaDataPath,
storageTotalBytes: $storageTotalBytes,
storageFreeBytes: $storageFreeBytes,
storageUsedBytes: $storageUsedBytes,
totalSources: $this->systemStatusRepository->countContentSources(),
sourcesByHealth: $this->systemStatusRepository->countContentSourcesByHealth(),
phpVersion: PHP_VERSION,
generatedAt: $now,
);
}
private function computeDirectorySize(string $path): int
{
if (!is_dir($path)) {
return 0;
}
$size = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
return $size;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domain\System\Domain\Contract\Repository;
interface SystemStatusRepositoryInterface
{
public function countMangas(): int;
public function countMonitoredMangas(): int;
/** @return array<string, int> */
public function countMangasByStatus(): array;
public function countChapters(): int;
public function countDownloadedChapters(): int;
public function countContentSources(): int;
/** @return array<string, int> */
public function countContentSourcesByHealth(): array;
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domain\System\Domain\Model;
readonly class SystemStatus
{
public function __construct(
// Mangas
public int $totalMangas,
public int $monitoredMangas,
/** @var array<string, int> */
public array $mangasByStatus,
// Chapitres
public int $totalChapters,
public int $downloadedChapters,
// Jobs global
public int $totalJobs,
public int $completedJobs,
public int $failedJobs,
public int $pendingJobs,
public int $inProgressJobs,
// Jobs 24h
public int $totalJobsLast24h,
public int $completedJobsLast24h,
public int $failedJobsLast24h,
// Jobs 7j
public int $totalJobsLast7d,
public int $completedJobsLast7d,
public int $failedJobsLast7d,
// Stockage
public string $storagePath,
public int $storageTotalBytes,
public int $storageFreeBytes,
public int $storageUsedBytes,
// Sources
public int $totalSources,
/** @var array<string, int> */
public array $sourcesByHealth,
// Système
public string $phpVersion,
public \DateTimeImmutable $generatedAt,
) {
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Domain\System\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\System\Infrastructure\ApiPlatform\State\Provider\GetSystemStatusStateProvider;
#[ApiResource(
shortName: 'System',
operations: [
new Get(
uriTemplate: '/system/status',
provider: GetSystemStatusStateProvider::class,
)
]
)]
class GetSystemStatusResource
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
public int $totalMangas = 0;
public int $monitoredMangas = 0;
/** @var array<string, int> */
public array $mangasByStatus = [];
public int $totalChapters = 0;
public int $downloadedChapters = 0;
public int $pendingChapters = 0;
public int $totalJobs = 0;
public int $completedJobs = 0;
public int $failedJobs = 0;
public int $pendingJobs = 0;
public int $inProgressJobs = 0;
public int $totalJobsLast24h = 0;
public int $completedJobsLast24h = 0;
public int $failedJobsLast24h = 0;
public float $successRateLast24h = 0.0;
public int $totalJobsLast7d = 0;
public int $completedJobsLast7d = 0;
public int $failedJobsLast7d = 0;
public float $successRateLast7d = 0.0;
public string $storagePath = '';
public int $storageTotalBytes = 0;
public int $storageFreeBytes = 0;
public int $storageUsedBytes = 0;
public string $storageTotalHuman = '';
public string $storageFreeHuman = '';
public string $storageUsedHuman = '';
public int $totalSources = 0;
/** @var array<string, int> */
public array $sourcesByHealth = [];
public string $phpVersion = '';
public string $generatedAt = '';
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Domain\System\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\System\Application\Query\GetSystemStatusQuery;
use App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler;
use App\Domain\System\Domain\Model\SystemStatus;
use App\Domain\System\Infrastructure\ApiPlatform\Resource\GetSystemStatusResource;
final class GetSystemStatusStateProvider implements ProviderInterface
{
public function __construct(
private readonly GetSystemStatusQueryHandler $handler,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GetSystemStatusResource
{
$response = $this->handler->handle(new GetSystemStatusQuery());
return $this->toResource($response);
}
private function toResource(SystemStatus $response): GetSystemStatusResource
{
$resource = new GetSystemStatusResource();
$resource->id = 'current';
$resource->totalMangas = $response->totalMangas;
$resource->monitoredMangas = $response->monitoredMangas;
$resource->mangasByStatus = $response->mangasByStatus;
$resource->totalChapters = $response->totalChapters;
$resource->downloadedChapters = $response->downloadedChapters;
$resource->pendingChapters = $response->totalChapters - $response->downloadedChapters;
$resource->totalJobs = $response->totalJobs;
$resource->completedJobs = $response->completedJobs;
$resource->failedJobs = $response->failedJobs;
$resource->pendingJobs = $response->pendingJobs;
$resource->inProgressJobs = $response->inProgressJobs;
$resource->totalJobsLast24h = $response->totalJobsLast24h;
$resource->completedJobsLast24h = $response->completedJobsLast24h;
$resource->failedJobsLast24h = $response->failedJobsLast24h;
$resource->successRateLast24h = $response->totalJobsLast24h > 0
? round($response->completedJobsLast24h / $response->totalJobsLast24h * 100, 1)
: 0.0;
$resource->totalJobsLast7d = $response->totalJobsLast7d;
$resource->completedJobsLast7d = $response->completedJobsLast7d;
$resource->failedJobsLast7d = $response->failedJobsLast7d;
$resource->successRateLast7d = $response->totalJobsLast7d > 0
? round($response->completedJobsLast7d / $response->totalJobsLast7d * 100, 1)
: 0.0;
$resource->storagePath = $response->storagePath;
$resource->storageTotalBytes = $response->storageTotalBytes;
$resource->storageFreeBytes = $response->storageFreeBytes;
$resource->storageUsedBytes = $response->storageUsedBytes;
$resource->storageTotalHuman = $this->formatBytes($response->storageTotalBytes);
$resource->storageFreeHuman = $this->formatBytes($response->storageFreeBytes);
$resource->storageUsedHuman = $this->formatBytes($response->storageUsedBytes);
$resource->totalSources = $response->totalSources;
$resource->sourcesByHealth = $response->sourcesByHealth;
$resource->phpVersion = $response->phpVersion;
$resource->generatedAt = $response->generatedAt->format(\DateTimeInterface::ATOM);
return $resource;
}
private function formatBytes(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$exp = (int) floor(log($bytes, 1024));
$exp = min($exp, count($units) - 1);
return round($bytes / (1024 ** $exp), 2) . ' ' . $units[$exp];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Domain\System\Infrastructure\Persistence\Repository;
use App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface;
use App\Entity\Chapter;
use App\Entity\ContentSource;
use App\Entity\Manga;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineSystemStatusRepository implements SystemStatusRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
public function countMangas(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m')
->getSingleScalarResult();
}
public function countMonitoredMangas(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m WHERE m.monitored = true')
->getSingleScalarResult();
}
public function countMangasByStatus(): array
{
$results = $this->entityManager
->createQuery('SELECT m.status, COUNT(m) as cnt FROM ' . Manga::class . ' m GROUP BY m.status')
->getResult();
$counts = [];
foreach ($results as $row) {
$status = $row['status'] ?? 'unknown';
$counts[$status] = (int) $row['cnt'];
}
return $counts;
}
public function countChapters(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c')
->getSingleScalarResult();
}
public function countDownloadedChapters(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c WHERE c.cbzPath IS NOT NULL')
->getSingleScalarResult();
}
public function countContentSources(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(s) FROM ' . ContentSource::class . ' s')
->getSingleScalarResult();
}
public function countContentSourcesByHealth(): array
{
$results = $this->entityManager
->createQuery('SELECT s.healthStatus, COUNT(s) as cnt FROM ' . ContentSource::class . ' s GROUP BY s.healthStatus')
->getResult();
$counts = [];
foreach ($results as $row) {
$counts[$row['healthStatus']] = (int) $row['cnt'];
}
return $counts;
}
}