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,36 @@
<template>
<StatusCard title="Chapitres" :icon="DocumentTextIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalChapters }}</span>
<span class="text-sm text-gray-500">total</span>
</div>
<div class="mb-1 flex justify-between text-xs text-gray-500">
<span>{{ status.downloadedChapters }} téléchargés</span>
<span>{{ downloadedPercent }}%</span>
</div>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-green-500 rounded-full transition-all"
:style="{ width: downloadedPercent + '%' }" />
</div>
<p class="mt-1 text-xs text-gray-400">{{ status.pendingChapters }} en attente</p>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { DocumentTextIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const downloadedPercent = computed(() => {
if (!props.status.totalChapters) return 0;
return Math.round((props.status.downloadedChapters / props.status.totalChapters) * 100);
});
</script>

View File

@@ -0,0 +1,104 @@
<template>
<StatusCard title="Jobs" :icon="CpuChipIcon">
<!-- Onglets -->
<div class="flex gap-1 mb-3 border-b border-gray-200 dark:border-gray-700">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeTab === tab.key
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'">
{{ tab.label }}
</button>
</div>
<!-- Contenu -->
<template v-if="activeTab === 'global'">
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobs" />
<Metric label="En cours" :value="status.inProgressJobs" color="blue" />
<Metric label="Terminés" :value="status.completedJobs" color="green" />
<Metric label="En attente" :value="status.pendingJobs" color="yellow" />
<Metric label="Échoués" :value="status.failedJobs" color="red" />
</div>
</template>
<template v-else-if="activeTab === '24h'">
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobsLast24h" />
<Metric label="Terminés" :value="status.completedJobsLast24h" color="green" />
<Metric label="Échoués" :value="status.failedJobsLast24h" color="red" />
<div class="col-span-2">
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
<span class="text-xl font-bold" :class="rateColor(status.successRateLast24h)">
{{ status.successRateLast24h }}%
</span>
</div>
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobsLast7d" />
<Metric label="Terminés" :value="status.completedJobsLast7d" color="green" />
<Metric label="Échoués" :value="status.failedJobsLast7d" color="red" />
<div class="col-span-2">
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
<span class="text-xl font-bold" :class="rateColor(status.successRateLast7d)">
{{ status.successRateLast7d }}%
</span>
</div>
</div>
</template>
</StatusCard>
</template>
<script setup>
import { ref } from 'vue';
import { CpuChipIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
defineProps({
status: {
type: Object,
required: true,
},
});
const activeTab = ref('global');
const tabs = [
{ key: 'global', label: 'Global' },
{ key: '24h', label: '24h' },
{ key: '7j', label: '7 jours' },
];
function rateColor(rate) {
if (rate >= 80) return 'text-green-600 dark:text-green-400';
if (rate >= 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
}
const Metric = {
props: {
label: String,
value: Number,
color: { type: String, default: 'gray' },
},
template: `
<div>
<p class="text-xs text-gray-500">{{ label }}</p>
<p class="text-lg font-semibold"
:class="{
'text-gray-900 dark:text-white': color === 'gray',
'text-green-600 dark:text-green-400': color === 'green',
'text-red-600 dark:text-red-400': color === 'red',
'text-yellow-600 dark:text-yellow-400': color === 'yellow',
'text-blue-600 dark:text-blue-400': color === 'blue',
}">{{ value }}</p>
</div>
`,
};
</script>

View File

@@ -0,0 +1,33 @@
<template>
<StatusCard title="Mangas" :icon="BookOpenIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalMangas }}</span>
<span class="text-sm text-gray-500">total</span>
<span class="ml-auto text-sm text-blue-600 dark:text-blue-400">{{ status.monitoredMangas }} suivis</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(count, label) in status.mangasByStatus"
:key="label"
class="px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400">
{{ label }}: {{ count }}
</span>
<span v-if="!hasStatuses" class="text-xs text-gray-400">Aucun statut disponible</span>
</div>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { BookOpenIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const hasStatuses = computed(() => Object.keys(props.status.mangasByStatus ?? {}).length > 0);
</script>

View File

@@ -0,0 +1,41 @@
<template>
<StatusCard title="Sources" :icon="GlobeAltIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalSources }}</span>
<span class="text-sm text-gray-500">sources configurées</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(count, health) in status.sourcesByHealth"
:key="health"
class="px-2 py-0.5 text-xs rounded-full"
:class="healthBadgeClass(health)">
{{ health }}: {{ count }}
</span>
<span v-if="!hasSources" class="text-xs text-gray-400">Aucune source</span>
</div>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { GlobeAltIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const hasSources = computed(() => Object.keys(props.status.sourcesByHealth ?? {}).length > 0);
function healthBadgeClass(health) {
switch (health) {
case 'healthy': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'unhealthy': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
}
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<component :is="icon" v-if="icon" class="w-5 h-5 text-blue-500 shrink-0" />
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ title }}</h3>
</div>
<slot />
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
icon: {
type: [Object, Function],
default: null,
},
});
</script>

View File

@@ -0,0 +1,40 @@
<template>
<StatusCard title="Stockage" :icon="CircleStackIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.storageUsedHuman }}</span>
<span class="text-sm text-gray-500">utilisés</span>
</div>
<div class="mb-1 flex justify-between text-xs text-gray-500">
<span>{{ status.storageFreeHuman }} libres</span>
<span>{{ usedPercent }}%</span>
</div>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="usedPercent > 90 ? 'bg-red-500' : 'bg-blue-500'"
:style="{ width: usedPercent + '%' }" />
</div>
<p class="mt-1 text-xs text-gray-400">Total : {{ status.storageTotalHuman }}</p>
<p class="mt-1 text-xs text-gray-400 truncate" :title="status.storagePath">{{ status.storagePath }}</p>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const diskUsedBytes = computed(() => props.status.storageTotalBytes - props.status.storageFreeBytes);
const usedPercent = computed(() => {
if (!props.status.storageTotalBytes) return 0;
return Math.round((diskUsedBytes.value / props.status.storageTotalBytes) * 100);
});
</script>

View File

@@ -0,0 +1,32 @@
<template>
<StatusCard title="Informations système" :icon="ServerIcon">
<dl class="space-y-2">
<div class="flex justify-between text-sm">
<dt class="text-gray-500">Version PHP</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ status.phpVersion }}</dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-500">Généré le</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ formattedDate }}</dd>
</div>
</dl>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { ServerIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const formattedDate = computed(() => {
if (!props.status.generatedAt) return '';
return new Date(props.status.generatedAt).toLocaleString('fr-FR');
});
</script>