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:
parent
c2b55e9018
commit
ca8791cc0d
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiStatusRepository } from '../../infrastructure/api/ApiStatusRepository';
|
||||||
|
|
||||||
|
const statusRepository = new ApiStatusRepository();
|
||||||
|
|
||||||
|
export const useStatusStore = defineStore('system-status', {
|
||||||
|
state: () => ({
|
||||||
|
status: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadStatus() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
this.status = await statusRepository.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message ?? 'Erreur lors du chargement du statut système';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export class ApiStatusRepository {
|
||||||
|
async getStatus() {
|
||||||
|
const response = await fetch('/api/system/status', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="px-6 py-8">
|
||||||
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 rounded">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
|
||||||
|
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="statusStore.loadStatus()"
|
||||||
|
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Données -->
|
||||||
|
<div v-else-if="status" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4">
|
||||||
|
<MangasStatusCard :status="status" />
|
||||||
|
<ChaptersStatusCard :status="status" />
|
||||||
|
<JobsStatusCard :status="status" />
|
||||||
|
<StorageStatusCard :status="status" />
|
||||||
|
<SourcesStatusCard :status="status" />
|
||||||
|
<SystemInfoCard :status="status" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, ExclamationCircleIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useStatusStore } from '../../application/store/statusStore';
|
||||||
|
import ChaptersStatusCard from '../components/ChaptersStatusCard.vue';
|
||||||
|
import JobsStatusCard from '../components/JobsStatusCard.vue';
|
||||||
|
import MangasStatusCard from '../components/MangasStatusCard.vue';
|
||||||
|
import SourcesStatusCard from '../components/SourcesStatusCard.vue';
|
||||||
|
import StorageStatusCard from '../components/StorageStatusCard.vue';
|
||||||
|
import SystemInfoCard from '../components/SystemInfoCard.vue';
|
||||||
|
|
||||||
|
const statusStore = useStatusStore();
|
||||||
|
const { status, loading, error } = storeToRefs(statusStore);
|
||||||
|
|
||||||
|
onMounted(() => statusStore.loadStatus());
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Statut système', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Rafraîchir',
|
||||||
|
disabled: loading.value,
|
||||||
|
onClick: () => statusStore.loadStatus(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
@@ -11,24 +11,9 @@ import ScrapperConfigurations from '../domain/setting/presentation/pages/Scrappe
|
|||||||
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||||
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
||||||
import LogsPage from '../domain/system/presentation/pages/LogsPage.vue';
|
import LogsPage from '../domain/system/presentation/pages/LogsPage.vue';
|
||||||
|
import StatusPage from '../domain/system/presentation/pages/StatusPage.vue';
|
||||||
import Layout from '../shared/components/layout/Layout.vue';
|
import Layout from '../shared/components/layout/Layout.vue';
|
||||||
|
|
||||||
// Placeholder component for new routes
|
|
||||||
const PlaceholderComponent = {
|
|
||||||
props: {
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
|
|
||||||
<p class="text-gray-600">Cette fonctionnalité sera bientôt disponible.</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -66,13 +51,6 @@ const routes = [
|
|||||||
name: 'import',
|
name: 'import',
|
||||||
component: NewImportPage
|
component: NewImportPage
|
||||||
},
|
},
|
||||||
// Pages placeholder avec chargement différé
|
|
||||||
{
|
|
||||||
path: '/manga/import',
|
|
||||||
name: 'manga-import',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Import de bibliothèque' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/manga/discover',
|
path: '/manga/discover',
|
||||||
name: 'discover',
|
name: 'discover',
|
||||||
@@ -91,21 +69,7 @@ const routes = [
|
|||||||
// Paramètres
|
// Paramètres
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'settings',
|
redirect: '/settings/scrappers',
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Paramètres' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings/general',
|
|
||||||
name: 'settings-general',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Paramètres généraux' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings/folders',
|
|
||||||
name: 'settings-folders',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Gestion des dossiers' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings/scrappers',
|
path: '/settings/scrappers',
|
||||||
@@ -130,33 +94,18 @@ const routes = [
|
|||||||
// Système
|
// Système
|
||||||
{
|
{
|
||||||
path: '/system',
|
path: '/system',
|
||||||
name: 'system',
|
redirect: '/system/status',
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Système' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/status',
|
path: '/system/status',
|
||||||
name: 'system-status',
|
name: 'system-status',
|
||||||
component: PlaceholderComponent,
|
component: StatusPage,
|
||||||
props: { title: 'Status du système' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/system/backup',
|
|
||||||
name: 'system-backup',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Sauvegarde' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/logs',
|
path: '/system/logs',
|
||||||
name: 'system-logs',
|
name: 'system-logs',
|
||||||
component: LogsPage,
|
component: LogsPage,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/system/updates',
|
|
||||||
name: 'system-updates',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Mises à jour' }
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -78,11 +78,9 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
|||||||
{
|
{
|
||||||
icon: Cog6ToothIcon,
|
icon: Cog6ToothIcon,
|
||||||
text: 'Paramètres',
|
text: 'Paramètres',
|
||||||
to: '/settings',
|
to: '/settings/scrappers',
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
subItems: [
|
subItems: [
|
||||||
{ icon: null, text: 'Général', to: '/settings/general' },
|
|
||||||
{ icon: null, text: 'Dossiers', to: '/settings/folders' },
|
|
||||||
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' },
|
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' },
|
||||||
{ icon: null, text: 'UI', to: '/settings/ui' }
|
{ icon: null, text: 'UI', to: '/settings/ui' }
|
||||||
]
|
]
|
||||||
@@ -90,13 +88,11 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
|||||||
{
|
{
|
||||||
icon: ComputerDesktopIcon,
|
icon: ComputerDesktopIcon,
|
||||||
text: 'Système',
|
text: 'Système',
|
||||||
to: '/system',
|
to: '/system/status',
|
||||||
id: 'system',
|
id: 'system',
|
||||||
subItems: [
|
subItems: [
|
||||||
{ icon: null, text: 'Status', to: '/system/status' },
|
{ icon: null, text: 'Status', to: '/system/status' },
|
||||||
{ icon: null, text: 'Backup', to: '/system/backup' },
|
|
||||||
{ icon: null, text: 'Logs', to: '/system/logs' },
|
{ icon: null, text: 'Logs', to: '/system/logs' },
|
||||||
{ icon: null, text: 'Updates', to: '/system/updates' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -34,5 +34,6 @@ api_platform:
|
|||||||
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
|
||||||
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
|
||||||
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
|
||||||
|
- '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource'
|
||||||
patch_formats:
|
patch_formats:
|
||||||
json: ['application/merge-patch+json']
|
json: ['application/merge-patch+json']
|
||||||
|
|||||||
@@ -200,3 +200,12 @@ services:
|
|||||||
# Import Domain API Platform Services
|
# Import Domain API Platform Services
|
||||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
|
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
|
||||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~
|
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~
|
||||||
|
|
||||||
|
# System Domain
|
||||||
|
App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface:
|
||||||
|
alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository
|
||||||
|
|
||||||
|
App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler:
|
||||||
|
arguments:
|
||||||
|
$mangaDataPath: '%env(resolve:MANGA_DATA_PATH)%'
|
||||||
|
$imagesStoragePath: '%kernel.project_dir%/public/images'
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\System\Application\Query;
|
||||||
|
|
||||||
|
final class GetSystemStatusQuery
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
44
src/Domain/System/Domain/Model/SystemStatus.php
Normal file
44
src/Domain/System/Domain/Model/SystemStatus.php
Normal 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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user