Compare commits
13 Commits
6065eb0eb1
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9926da6730 | ||
| 4c80aa6b42 | |||
| c0307a9173 | |||
|
|
45f7e88024 | ||
| 507fac5b5e | |||
| 071e12a06c | |||
|
|
59f72339fa | ||
| 3963efa986 | |||
|
|
ca8791cc0d | ||
| c2b55e9018 | |||
|
|
07d1b2daed | ||
| a7e6879e83 | |||
|
|
fa035bfbfa |
@@ -1,13 +1,17 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { Job } from '../../domain/entities/job';
|
||||
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
||||
|
||||
const jobRepository = new ApiJobRepository();
|
||||
|
||||
const ACTIVE_STATUSES = ['pending', 'in_progress'];
|
||||
|
||||
export const useActivityStore = defineStore('activity', {
|
||||
state: () => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
mercureEventSource: null,
|
||||
// Pagination
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
@@ -15,21 +19,15 @@ export const useActivityStore = defineStore('activity', {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
// Filtres
|
||||
filter: {
|
||||
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC'
|
||||
}
|
||||
// Tri
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
activeJobs: state => state.jobs.filter(job => job.isActive()),
|
||||
completedJobs: state => state.jobs.filter(job => job.isCompleted()),
|
||||
failedJobs: state => state.jobs.filter(job => job.hasError()),
|
||||
isLoading: state => state.loading,
|
||||
hasError: state => !!state.error,
|
||||
// Getters pour la pagination
|
||||
paginationInfo: state => ({
|
||||
currentPage: state.currentPage,
|
||||
totalPages: state.totalPages,
|
||||
@@ -41,44 +39,25 @@ export const useActivityStore = defineStore('activity', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Charge la liste des jobs selon les filtres actuels
|
||||
* @param {number} page - Numéro de page optionnel
|
||||
*/
|
||||
async loadJobs(page = null) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
const jobCollection = await jobRepository.getJobs({
|
||||
page: page || this.currentPage,
|
||||
limit: this.limit,
|
||||
sortBy: this.filter.sortBy,
|
||||
sortOrder: this.filter.sortOrder,
|
||||
status: this.filter.status
|
||||
};
|
||||
sortBy: this.sortBy,
|
||||
sortOrder: this.sortOrder,
|
||||
status: ACTIVE_STATUSES,
|
||||
});
|
||||
|
||||
const jobCollection = await jobRepository.getJobs(options);
|
||||
|
||||
// Mettre à jour les données
|
||||
this.jobs = jobCollection.items;
|
||||
this.currentPage = jobCollection.page;
|
||||
this.total = jobCollection.total;
|
||||
this.hasNextPage = jobCollection.hasNextPage;
|
||||
this.hasPreviousPage = jobCollection.hasPreviousPage;
|
||||
|
||||
// Calculer le nombre total de pages
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
|
||||
console.log('Store updated with:', {
|
||||
jobs: this.jobs.length,
|
||||
currentPage: this.currentPage,
|
||||
total: this.total,
|
||||
limit: this.limit,
|
||||
totalPages: this.totalPages,
|
||||
hasNextPage: this.hasNextPage,
|
||||
hasPreviousPage: this.hasPreviousPage
|
||||
});
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
console.error('Error loading jobs:', error);
|
||||
@@ -87,10 +66,6 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Va à une page spécifique
|
||||
* @param {number} page
|
||||
*/
|
||||
async goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
@@ -98,39 +73,26 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour les filtres et recharge la liste
|
||||
* @param {Object} filter
|
||||
*/
|
||||
async updateFilter(filter) {
|
||||
this.filter = { ...this.filter, ...filter };
|
||||
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
|
||||
async updateSort(sortBy, sortOrder) {
|
||||
this.sortBy = sortBy;
|
||||
this.sortOrder = sortOrder;
|
||||
this.currentPage = 1;
|
||||
await this.loadJobs(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour la limite par page
|
||||
* @param {number} limit
|
||||
*/
|
||||
async updateLimit(limit) {
|
||||
this.limit = limit;
|
||||
this.currentPage = 1; // Retourner à la première page
|
||||
this.currentPage = 1;
|
||||
await this.loadJobs(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime un job par son ID
|
||||
* @param {string} id
|
||||
*/
|
||||
async deleteJob(id) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await jobRepository.deleteJob(id);
|
||||
// Supprimer le job de la liste locale
|
||||
this.jobs = this.jobs.filter(job => job.id !== id);
|
||||
// Recharger la page courante pour avoir les bons totaux
|
||||
await this.loadJobs(this.currentPage);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
@@ -140,17 +102,75 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs correspondant aux critères
|
||||
* @param {Object} criteria
|
||||
*/
|
||||
updateJobProgress(jobId, progress) {
|
||||
const job = this.jobs.find(j => j.id === jobId);
|
||||
if (job) job.progress = progress;
|
||||
},
|
||||
|
||||
handleJobCreated(data) {
|
||||
const alreadyExists = this.jobs.some(j => j.id === data.id);
|
||||
if (alreadyExists) return;
|
||||
|
||||
const job = Job.create({
|
||||
id: data.id,
|
||||
type: data.type_job,
|
||||
status: data.status,
|
||||
createdAt: data.createdAt,
|
||||
context: data.context,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
});
|
||||
|
||||
this.jobs.unshift(job);
|
||||
this.total += 1;
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
},
|
||||
|
||||
handleJobStatusChange(jobId, newStatus) {
|
||||
const job = this.jobs.find(j => j.id === jobId);
|
||||
if (!job) return;
|
||||
|
||||
if (newStatus === 'in_progress') {
|
||||
job.status = 'in_progress';
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.jobs = this.jobs.filter(j => j.id !== jobId);
|
||||
this.total = Math.max(0, this.total - 1);
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
|
||||
subscribeMercure() {
|
||||
if (this.mercureEventSource) return;
|
||||
const url = new URL('/.well-known/mercure', window.location.origin);
|
||||
url.searchParams.append('topic', 'jobs/activity');
|
||||
this.mercureEventSource = new EventSource(url.toString());
|
||||
this.mercureEventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'job.created') {
|
||||
this.handleJobCreated(data);
|
||||
} else if (data.type === 'job.progress_updated') {
|
||||
this.updateJobProgress(data.jobId, data.progress);
|
||||
} else if (data.type === 'job.status_changed') {
|
||||
this.handleJobStatusChange(data.jobId, data.status);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
unsubscribeMercure() {
|
||||
if (this.mercureEventSource) {
|
||||
this.mercureEventSource.close();
|
||||
this.mercureEventSource = null;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteJobs(criteria = {}) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const deleted = await jobRepository.deleteJobs(criteria);
|
||||
// Recharger la liste après suppression
|
||||
await this.loadJobs(this.currentPage);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
@@ -160,26 +180,5 @@ export const useActivityStore = defineStore('activity', {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs terminés
|
||||
*/
|
||||
async deleteCompletedJobs() {
|
||||
return this.deleteJobs({ status: ['COMPLETED'] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs en erreur
|
||||
*/
|
||||
async deleteFailedJobs() {
|
||||
return this.deleteJobs({ status: ['ERROR'] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs
|
||||
*/
|
||||
async deleteAllJobs() {
|
||||
return this.deleteJobs({});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,169 +1,153 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto h-full">
|
||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
<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-indigo-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
|
||||
<p>{{ activityStore.error }}</p>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<div v-else-if="activityStore.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">
|
||||
<p class="text-red-800 dark:text-red-200">{{ activityStore.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="container mx-auto p-2">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white dark:bg-gray-800">
|
||||
<!-- Content -->
|
||||
<section v-else class="border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Empty -->
|
||||
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||
<ClockIcon class="w-12 h-12 mb-3" />
|
||||
<p class="text-base">Aucun job en cours ou en attente.</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
|
||||
<th class="w-1/12 py-3 px-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox h-5 w-5 text-green-600"
|
||||
@change="toggleSelectAll" />
|
||||
</th>
|
||||
<th class="w-2/12 py-3 px-4 text-left">Type</th>
|
||||
<th class="w-2/12 py-3 px-4 text-left">Statut</th>
|
||||
<th class="w-3/12 py-3 px-4 text-left">Informations</th>
|
||||
<th class="w-3/12 py-3 px-4 text-left">Progression</th>
|
||||
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
<th class="w-2/11 py-3 px-6 text-left">Type</th>
|
||||
<th class="w-2/11 py-3 px-4 text-left">Statut</th>
|
||||
<th class="w-3/11 py-3 px-4 text-left">Informations</th>
|
||||
<th class="w-3/11 py-3 px-4 text-left">Progression</th>
|
||||
<th class="w-1/11 py-3 px-4 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700 dark:text-gray-300">
|
||||
<template v-if="activityStore.jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
|
||||
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<JobItem
|
||||
v-for="job in activityStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@delete="deleteJob" />
|
||||
</template>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300">
|
||||
<JobItem
|
||||
v-for="job in activityStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@delete="deleteJob" />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="activityStore.total > activityStore.limit"
|
||||
v-if="total > activityStore.limit"
|
||||
:current-page="activityStore.currentPage"
|
||||
:total-pages="activityStore.totalPages"
|
||||
:total="activityStore.total"
|
||||
:total="total"
|
||||
:limit="activityStore.limit"
|
||||
:has-next-page="activityStore.hasNextPage"
|
||||
:has-previous-page="activityStore.hasPreviousPage"
|
||||
@page-change="changePage" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useActivityStore } from '../../application/store/activityStore';
|
||||
import JobItem from '../components/JobItem.vue';
|
||||
|
||||
const activityStore = useActivityStore();
|
||||
const selectedAll = ref(false);
|
||||
const activityStore = useActivityStore();
|
||||
|
||||
// Statuts disponibles pour le filtre
|
||||
const statusOptions = [
|
||||
{ value: ['pending', 'in_progress'], label: 'Actifs' },
|
||||
{ value: ['pending', 'in_progress', 'completed', 'failed'], label: 'Tous' },
|
||||
{ value: ['completed'], label: 'Terminés' },
|
||||
{ value: ['failed'], label: 'En erreur' },
|
||||
{ value: ['pending'], label: 'En attente' },
|
||||
{ value: ['in_progress'], label: 'En cours' }
|
||||
];
|
||||
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
|
||||
|
||||
// Index du statut actif (par défaut "Actifs")
|
||||
const activeStatusIndex = ref(0);
|
||||
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||
|
||||
// Configuration de la toolbar réactive
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
icon: FunnelIcon,
|
||||
type: 'dropdown',
|
||||
label: statusOptions[activeStatusIndex.value].label,
|
||||
active: false,
|
||||
items: statusOptions.map((option, index) => ({
|
||||
label: option.label,
|
||||
isSelected: index === activeStatusIndex.value,
|
||||
onClick: () => setStatusFilter(index)
|
||||
}))
|
||||
}
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
type: 'button',
|
||||
label: 'Rafraîchir',
|
||||
onClick: refreshJobs
|
||||
},
|
||||
{
|
||||
icon: TrashIcon,
|
||||
type: 'button',
|
||||
label: 'Supprimer visibles',
|
||||
onClick: deleteVisibleJobs
|
||||
}
|
||||
]
|
||||
}));
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' },
|
||||
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'dropdown',
|
||||
icon: BarsArrowDownIcon,
|
||||
label: 'Trier',
|
||||
items: [
|
||||
{
|
||||
label: 'Plus récent',
|
||||
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||
onClick: () => activityStore.updateSort('createdAt', 'DESC'),
|
||||
},
|
||||
{
|
||||
label: 'Plus ancien',
|
||||
isSelected: isSortSelected('createdAt', 'ASC'),
|
||||
onClick: () => activityStore.updateSort('createdAt', 'ASC'),
|
||||
},
|
||||
{
|
||||
label: 'Par type',
|
||||
isSelected: isSortSelected('type', 'ASC'),
|
||||
onClick: () => activityStore.updateSort('type', 'ASC'),
|
||||
},
|
||||
{
|
||||
label: 'Par statut',
|
||||
isSelected: isSortSelected('status', 'ASC'),
|
||||
onClick: () => activityStore.updateSort('status', 'ASC'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Rafraîchir',
|
||||
disabled: loading.value,
|
||||
onClick: () => activityStore.loadJobs(),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: TrashIcon,
|
||||
label: 'Supprimer visibles',
|
||||
disabled: loading.value || total.value === 0,
|
||||
onClick: deleteVisibleJobs,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
loadJobs();
|
||||
});
|
||||
onMounted(() => {
|
||||
activityStore.loadJobs();
|
||||
activityStore.subscribeMercure();
|
||||
});
|
||||
|
||||
function loadJobs() {
|
||||
activityStore.loadJobs();
|
||||
onUnmounted(() => {
|
||||
activityStore.unsubscribeMercure();
|
||||
});
|
||||
|
||||
function changePage(page) {
|
||||
activityStore.goToPage(page);
|
||||
}
|
||||
|
||||
function deleteJob(id) {
|
||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||
activityStore.deleteJob(id);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshJobs() {
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
activityStore.goToPage(page);
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectedAll.value = !selectedAll.value;
|
||||
// La logique pour sélectionner tous les jobs serait ajoutée ici
|
||||
}
|
||||
|
||||
function setStatusFilter(index) {
|
||||
if (index >= 0 && index < statusOptions.length) {
|
||||
activeStatusIndex.value = index;
|
||||
activityStore.updateFilter({ status: statusOptions[index].value });
|
||||
}
|
||||
}
|
||||
|
||||
function deleteJob(id) {
|
||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||
activityStore.deleteJob(id);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteVisibleJobs() {
|
||||
if (activityStore.jobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
|
||||
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
|
||||
activityStore.deleteJobs({ status: activityStore.filter.status });
|
||||
}
|
||||
function deleteVisibleJobs() {
|
||||
if (activityStore.jobs.length === 0) return;
|
||||
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
|
||||
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -94,14 +94,14 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
};
|
||||
|
||||
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
|
||||
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus
|
||||
const getPlaceholderHeight = (page) => {
|
||||
const dims = page?.dimensions;
|
||||
if (!dims?.width || !dims?.height) return 800;
|
||||
if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom);
|
||||
const displayWidth = windowWidth.value < 1200
|
||||
? Math.min(dims.width, windowWidth.value * 0.95)
|
||||
: Math.min(dims.width, 1200);
|
||||
return Math.round((dims.height / dims.width) * displayWidth);
|
||||
return Math.round((dims.height / dims.width) * displayWidth * props.zoom);
|
||||
};
|
||||
|
||||
const setupObservers = () => {
|
||||
@@ -109,7 +109,7 @@ import ReaderPage from './ReaderPage.vue';
|
||||
visibilityObserver.value?.disconnect();
|
||||
|
||||
observer.value = new IntersectionObserver(observeIntersection, {
|
||||
root: null,
|
||||
root: containerRef.value,
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ import ReaderPage from './ReaderPage.vue';
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
|
||||
{ root: containerRef.value, rootMargin: '1000px 0px', threshold: 0 }
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
@@ -328,7 +328,6 @@ import ReaderPage from './ReaderPage.vue';
|
||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
||||
/* Réduction du padding sur mobile */
|
||||
@apply py-2 sm:py-8;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
|
||||
@@ -87,13 +87,9 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
|
||||
const store = useReaderStore();
|
||||
|
||||
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
|
||||
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
|
||||
// zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll)
|
||||
const containerStyle = computed(() => {
|
||||
if (store.readingMode === 'single') {
|
||||
return { zoom: props.zoom };
|
||||
}
|
||||
return { transform: `scale(${props.zoom})` };
|
||||
return { zoom: props.zoom };
|
||||
});
|
||||
|
||||
const imageRef = ref(null);
|
||||
|
||||
@@ -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 UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.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';
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -66,13 +51,6 @@ const routes = [
|
||||
name: 'import',
|
||||
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',
|
||||
name: 'discover',
|
||||
@@ -91,21 +69,7 @@ const routes = [
|
||||
// Paramètres
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
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' }
|
||||
redirect: '/settings/scrappers',
|
||||
},
|
||||
{
|
||||
path: '/settings/scrappers',
|
||||
@@ -130,33 +94,18 @@ const routes = [
|
||||
// Système
|
||||
{
|
||||
path: '/system',
|
||||
name: 'system',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Système' }
|
||||
redirect: '/system/status',
|
||||
},
|
||||
{
|
||||
path: '/system/status',
|
||||
name: 'system-status',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Status du système' }
|
||||
},
|
||||
{
|
||||
path: '/system/backup',
|
||||
name: 'system-backup',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Sauvegarde' }
|
||||
component: StatusPage,
|
||||
},
|
||||
{
|
||||
path: '/system/logs',
|
||||
name: 'system-logs',
|
||||
component: LogsPage,
|
||||
},
|
||||
{
|
||||
path: '/system/updates',
|
||||
name: 'system-updates',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Mises à jour' }
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
||||
isReaderMode ? '' : 'md:ml-60'
|
||||
]" style="transition: margin-top 300ms ease-in-out;">
|
||||
<RouterView></RouterView>
|
||||
<RouterView class="flex-1 min-h-0"></RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -78,11 +78,9 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
{
|
||||
icon: Cog6ToothIcon,
|
||||
text: 'Paramètres',
|
||||
to: '/settings',
|
||||
to: '/settings/scrappers',
|
||||
id: 'settings',
|
||||
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: 'UI', to: '/settings/ui' }
|
||||
]
|
||||
@@ -90,13 +88,11 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
{
|
||||
icon: ComputerDesktopIcon,
|
||||
text: 'Système',
|
||||
to: '/system',
|
||||
to: '/system/status',
|
||||
id: 'system',
|
||||
subItems: [
|
||||
{ icon: null, text: 'Status', to: '/system/status' },
|
||||
{ icon: null, text: 'Backup', to: '/system/backup' },
|
||||
{ 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/Conversion/Infrastructure/ApiPlatform/Resource'
|
||||
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
|
||||
- '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource'
|
||||
patch_formats:
|
||||
json: ['application/merge-patch+json']
|
||||
|
||||
@@ -17,7 +17,6 @@ framework:
|
||||
command.bus:
|
||||
middleware:
|
||||
- validation
|
||||
- doctrine_transaction
|
||||
event.bus:
|
||||
default_middleware: allow_no_handlers
|
||||
|
||||
|
||||
@@ -200,3 +200,12 @@ services:
|
||||
# Import Domain API Platform Services
|
||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
|
||||
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'
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace App\Domain\Scraping\Application\Command;
|
||||
readonly class ScrapeChapter
|
||||
{
|
||||
public function __construct(
|
||||
public string $chapterId
|
||||
public string $chapterId,
|
||||
public string $jobId
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,11 @@ use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||
use App\Domain\Scraping\Domain\Model\Source;
|
||||
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
|
||||
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
readonly class ScrapeChapterHandler
|
||||
{
|
||||
@@ -33,151 +30,92 @@ readonly class ScrapeChapterHandler
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private SourceRepositoryInterface $sourceRepository,
|
||||
private MessageBusInterface $eventBus,
|
||||
private EntityManagerInterface $entityManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ScrapeChapter $command): void
|
||||
{
|
||||
$job = null;
|
||||
try {
|
||||
// 1. Récupération du chapitre
|
||||
/**@var Chapter $chapter */
|
||||
$chapter = $this->chapterRepository->getById($command->chapterId);
|
||||
if (!$chapter) {
|
||||
throw new \InvalidArgumentException("Chapter not found with ID: {$command->chapterId}");
|
||||
}
|
||||
/** @var Chapter $chapter */
|
||||
$chapter = $this->chapterRepository->getById($command->chapterId);
|
||||
$manga = $this->mangaRepository->getById($chapter->mangaId);
|
||||
|
||||
// 2. Récupération du manga
|
||||
$manga = $this->mangaRepository->getById($chapter->mangaId);
|
||||
if (!$manga) {
|
||||
throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}");
|
||||
}
|
||||
$job = $this->jobRepository->get($command->jobId);
|
||||
$job->context['chapterId'] = $command->chapterId;
|
||||
$job->context['mangaTitle'] = $manga->getTitle();
|
||||
$job->start();
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
// 3. Dispatch de l'événement de démarrage
|
||||
$this->eventBus->dispatch(new ChapterScrapingStarted($manga->getTitle(), $chapter->chapterNumber));
|
||||
$this->eventBus->dispatch(new ChapterScrapingStarted($job->id, $manga->getTitle(), $chapter->chapterNumber));
|
||||
|
||||
// 4. Détermination des sources à utiliser
|
||||
$sources = $this->getSourcesToTry($manga);
|
||||
if (empty($sources)) {
|
||||
throw new \InvalidArgumentException("No sources available for scraping");
|
||||
}
|
||||
$sources = $this->getSourcesToTry($manga);
|
||||
$slugsToTry = array_merge([$manga->getSlug()], $manga->getAlternativeSlugs());
|
||||
$success = false;
|
||||
$lastException = null;
|
||||
|
||||
// 5. Essai de scraping sur chaque source jusqu'à succès
|
||||
$success = false;
|
||||
$lastException = null;
|
||||
|
||||
foreach ($sources as $source) {
|
||||
// Préparer la liste des slugs à essayer : slug principal + slugs alternatifs
|
||||
$slugsToTry = array_merge([$manga->getSlug()], $manga->getAlternativeSlugs());
|
||||
|
||||
foreach ($slugsToTry as $slug) {
|
||||
$job = new ScrapingJob(
|
||||
Uuid::uuid4()->toString(),
|
||||
$chapter->mangaId,
|
||||
$chapter->chapterNumber,
|
||||
$source->getId()->getValue()
|
||||
);
|
||||
|
||||
// Ajout de l'ID du chapitre et du slug dans le contexte du job
|
||||
$job->context['chapterId'] = $command->chapterId;
|
||||
foreach ($sources as $source) {
|
||||
foreach ($slugsToTry as $slug) {
|
||||
try {
|
||||
$job->context['sourceId'] = $source->getId()->getValue();
|
||||
$job->context['slug'] = $slug;
|
||||
$job->context['mangaTitle'] = $manga->getTitle();
|
||||
|
||||
$job->start();
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
try {
|
||||
$this->entityManager->beginTransaction();
|
||||
$scrapingParameters = $source->getScrappingParameters();
|
||||
$scrapingParameters['chapterNumber'] = $chapter->chapterNumber;
|
||||
$scrapingType = $scrapingParameters['scrapingType'] ?? 'html';
|
||||
|
||||
// 5. Scraping des URLs avec le slug courant
|
||||
$scrapingParameters = $source->getScrappingParameters();
|
||||
$scrapingParameters['chapterNumber'] = $chapter->chapterNumber;
|
||||
$scrapingType = $scrapingParameters['scrapingType'] ?? 'html';
|
||||
$scrapingRequest = new ScrapingRequest(
|
||||
$scrapingType,
|
||||
$source->buildChapterUrl($slug, $chapter->chapterNumber),
|
||||
$scrapingParameters
|
||||
);
|
||||
|
||||
$scrapingRequest = new ScrapingRequest(
|
||||
$scrapingType,
|
||||
$source->buildChapterUrl($slug, $chapter->chapterNumber),
|
||||
$scrapingParameters
|
||||
);
|
||||
$scraper = $this->scraperFactory->getScraperWithFallback($scrapingType);
|
||||
$scrapingResult = $scraper->scrape($scrapingRequest);
|
||||
|
||||
// Sélection du scraper approprié selon le type
|
||||
$scraper = $this->scraperFactory->getScraperWithFallback($scrapingType);
|
||||
$scrapingResult = $scraper->scrape($scrapingRequest);
|
||||
$tempDir = new TempDirectory();
|
||||
$downloadResults = $this->imageDownloader->downloadBatch(
|
||||
$scrapingResult->getImageUrls(),
|
||||
$tempDir,
|
||||
$job->id
|
||||
);
|
||||
|
||||
// 6. Téléchargement des images
|
||||
$tempDir = new TempDirectory();
|
||||
$downloadResults = $this->imageDownloader->downloadBatch(
|
||||
$scrapingResult->getImageUrls(),
|
||||
$tempDir,
|
||||
$job->id
|
||||
);
|
||||
$localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults);
|
||||
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths);
|
||||
$pageCount = count($downloadResults);
|
||||
|
||||
// 7. Stockage des images individuelles
|
||||
$localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults);
|
||||
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths);
|
||||
$pageCount = count($downloadResults);
|
||||
$job->complete();
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
$job->complete();
|
||||
$this->jobRepository->save($job);
|
||||
$this->eventBus->dispatch(new ChapterScraped($job->id, $command->chapterId, $pagesDirectory, $pageCount));
|
||||
$tempDir->cleanup();
|
||||
|
||||
$this->entityManager->commit();
|
||||
|
||||
$this->eventBus->dispatch(new ChapterScraped($job->id, $command->chapterId, $pagesDirectory, $pageCount));
|
||||
|
||||
// 8. Nettoyage
|
||||
$tempDir->cleanup();
|
||||
|
||||
// Scraping réussi, pas besoin d'essayer d'autres slugs ni d'autres sources
|
||||
$success = true;
|
||||
break;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
dump('EXCEPTION for source ' . $source->getName() . ' with slug ' . $slug . ': ' . $e->getMessage());
|
||||
|
||||
$this->entityManager->rollback();
|
||||
|
||||
if (isset($job)) {
|
||||
$job->fail($e->getMessage());
|
||||
$this->jobRepository->save($job);
|
||||
}
|
||||
|
||||
$lastException = $e;
|
||||
|
||||
// Continuer avec le slug suivant pour cette source
|
||||
}
|
||||
}
|
||||
|
||||
// Si le scraping a réussi avec un des slugs, sortir de la boucle des sources
|
||||
if ($success) {
|
||||
$success = true;
|
||||
break;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$lastException = $e;
|
||||
}
|
||||
}
|
||||
|
||||
// Si toutes les sources ont échoué
|
||||
if (!$success) {
|
||||
$errorMessage = $lastException ? $lastException->getMessage() : "Failed to scrape chapter from all available sources";
|
||||
$this->eventBus->dispatch(new ChapterScrapingFailed($chapter->mangaId, $chapter->chapterNumber, $errorMessage));
|
||||
if ($success) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if (isset($job)) {
|
||||
$job->fail($e->getMessage());
|
||||
$this->jobRepository->save($job);
|
||||
}
|
||||
$this->eventBus->dispatch(new ChapterScrapingFailed($chapter->mangaId ?? 'unknown', $chapter->chapterNumber ?? 'unknown', $e->getMessage()));
|
||||
if (!$success) {
|
||||
$errorMessage = $lastException?->getMessage() ?? 'Failed to scrape chapter from all available sources';
|
||||
$job->fail($errorMessage);
|
||||
$this->jobRepository->save($job);
|
||||
$this->eventBus->dispatch(new ChapterScrapingFailed($job->id, $chapter->mangaId, $chapter->chapterNumber, $errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine les sources à utiliser pour le scraping en fonction des préférences du manga
|
||||
*
|
||||
* @param \App\Domain\Scraping\Domain\Model\Manga $manga
|
||||
* @return Source[]
|
||||
*/
|
||||
private function getSourcesToTry(\App\Domain\Scraping\Domain\Model\Manga $manga): array
|
||||
{
|
||||
// Si le manga a des sources préférées, les utiliser
|
||||
if ($manga->hasPreferredSources()) {
|
||||
$preferredSources = [];
|
||||
foreach ($manga->getPreferredSources() as $sourceId) {
|
||||
@@ -186,7 +124,6 @@ readonly class ScrapeChapterHandler
|
||||
$preferredSources[] = $source;
|
||||
}
|
||||
|
||||
// Limiter à 3 sources préférées maximum
|
||||
if (count($preferredSources) >= 3) {
|
||||
break;
|
||||
}
|
||||
@@ -197,7 +134,6 @@ readonly class ScrapeChapterHandler
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon, utiliser toutes les sources disponibles
|
||||
return $this->sourceRepository->getAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace App\Domain\Scraping\Domain\Event;
|
||||
readonly class ChapterScrapingFailed
|
||||
{
|
||||
public function __construct(
|
||||
private string $jobId,
|
||||
private string $mangaId,
|
||||
private string $chapterNumber,
|
||||
private string $reason
|
||||
) {
|
||||
}
|
||||
|
||||
public function getJobId(): string
|
||||
{
|
||||
return $this->jobId;
|
||||
}
|
||||
|
||||
public function getMangaId(): string
|
||||
{
|
||||
return $this->mangaId;
|
||||
|
||||
@@ -5,11 +5,17 @@ namespace App\Domain\Scraping\Domain\Event;
|
||||
class ChapterScrapingStarted
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $jobId,
|
||||
private readonly string $mangaTitle,
|
||||
private readonly float $chapterNumber,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getJobId(): string
|
||||
{
|
||||
return $this->jobId;
|
||||
}
|
||||
|
||||
public function getMangaTitle(): string
|
||||
{
|
||||
return $this->mangaTitle;
|
||||
|
||||
@@ -8,9 +8,9 @@ class ScrapingJob extends Job
|
||||
{
|
||||
public function __construct(
|
||||
string $id,
|
||||
string $mangaId,
|
||||
float $chapterNumber,
|
||||
string $sourceId
|
||||
?string $mangaId = null,
|
||||
?float $chapterNumber = null,
|
||||
?string $sourceId = null
|
||||
) {
|
||||
parent::__construct($id, 'scraping_job');
|
||||
$this->maxAttempts = 1;
|
||||
|
||||
@@ -5,13 +5,20 @@ namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||
use App\Domain\Scraping\Infrastructure\ApiPlatform\Dto\ScrapeChapterRequest;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class ScrapeChapterStateProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageBusInterface $commandBus
|
||||
private readonly MessageBusInterface $commandBus,
|
||||
private readonly JobRepositoryInterface $jobRepository,
|
||||
private readonly HubInterface $hub,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -20,10 +27,25 @@ final class ScrapeChapterStateProcessor implements ProcessorInterface
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||
{
|
||||
$this->commandBus->dispatch(
|
||||
new ScrapeChapter(
|
||||
$data->chapterId
|
||||
)
|
||||
);
|
||||
$jobId = Uuid::uuid4()->toString();
|
||||
$job = new ScrapingJob($jobId);
|
||||
$job->context['chapterId'] = $data->chapterId;
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
$this->hub->publish(new Update(
|
||||
'jobs/activity',
|
||||
json_encode([
|
||||
'type' => 'job.created',
|
||||
'id' => $job->id,
|
||||
'type_job' => $job->type,
|
||||
'status' => $job->status->value,
|
||||
'createdAt' => $job->createdAt->format('c'),
|
||||
'context' => $job->context,
|
||||
'attempts' => $job->attempts,
|
||||
'maxAttempts' => $job->maxAttempts,
|
||||
])
|
||||
));
|
||||
|
||||
$this->commandBus->dispatch(new ScrapeChapter($data->chapterId, $jobId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Event\PageScrapingProgressed;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||
@@ -30,14 +31,32 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function onPageScrapingProgressed(PageScrapingProgressed $event): void
|
||||
{
|
||||
$progress = (int) round($event->getProgress()->getPercentage());
|
||||
|
||||
$update = new Update(
|
||||
'jobs/activity',
|
||||
json_encode([
|
||||
'type' => 'job.progress_updated',
|
||||
'jobId' => $event->getJobId(),
|
||||
'progress' => $progress,
|
||||
])
|
||||
);
|
||||
$this->hub->publish($update);
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function onChapterScrapingStarted(ChapterScrapingStarted $event): void
|
||||
{
|
||||
$chapterNumber = $event->getChapterNumber();
|
||||
$mangaTitle = $event->getMangaTitle();
|
||||
$this->hub->publish(new Update(
|
||||
'jobs/activity',
|
||||
json_encode(['type' => 'job.status_changed', 'jobId' => $event->getJobId(), 'status' => 'in_progress'])
|
||||
));
|
||||
|
||||
$this->notification->sendInfo(
|
||||
sprintf('Scraping du chapitre %s de "%s" démarré', $chapterNumber, $mangaTitle)
|
||||
sprintf('Scraping du chapitre %s de "%s" démarré', $event->getChapterNumber(), $event->getMangaTitle())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,6 +103,11 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
$update = new Update($topics, json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
|
||||
$this->hub->publish(new Update(
|
||||
'jobs/activity',
|
||||
json_encode(['type' => 'job.status_changed', 'jobId' => $jobId, 'status' => 'completed'])
|
||||
));
|
||||
|
||||
$mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu';
|
||||
$this->notification->sendSuccess(
|
||||
sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle)
|
||||
@@ -93,6 +117,11 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
#[AsMessageHandler]
|
||||
public function onChapterScrapingFailed(ChapterScrapingFailed $event): void
|
||||
{
|
||||
$this->hub->publish(new Update(
|
||||
'jobs/activity',
|
||||
json_encode(['type' => 'job.status_changed', 'jobId' => $event->getJobId(), 'status' => 'failed'])
|
||||
));
|
||||
|
||||
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
||||
|
||||
$data = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
|
||||
@@ -16,8 +17,6 @@ use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
|
||||
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ScrapeChapterHandlerTest extends TestCase
|
||||
@@ -30,7 +29,6 @@ class ScrapeChapterHandlerTest extends TestCase
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private InMemorySourceRepository $sourceRepository;
|
||||
private InMemoryEventBus $eventBus;
|
||||
private EntityManagerInterface|MockObject $entityManager;
|
||||
private ScrapeChapterHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -43,11 +41,6 @@ class ScrapeChapterHandlerTest extends TestCase
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->sourceRepository = new InMemorySourceRepository();
|
||||
$this->eventBus = new InMemoryEventBus();
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$this->entityManager->method('beginTransaction')->willReturn(null);
|
||||
$this->entityManager->method('commit')->willReturn(null);
|
||||
$this->entityManager->method('rollback')->willReturn(null);
|
||||
|
||||
$this->chapterRepository->save(new Chapter(
|
||||
id: '1',
|
||||
@@ -65,26 +58,27 @@ class ScrapeChapterHandlerTest extends TestCase
|
||||
$this->mangaRepository,
|
||||
$this->sourceRepository,
|
||||
$this->eventBus,
|
||||
$this->entityManager
|
||||
);
|
||||
}
|
||||
|
||||
public function testHandleSuccessfully(): void
|
||||
{
|
||||
$command = new ScrapeChapter(
|
||||
chapterId: '1'
|
||||
);
|
||||
$jobId = 'test-job-id';
|
||||
$job = new ScrapingJob($jobId, 'test-manga', 2);
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
$command = new ScrapeChapter(chapterId: '1', jobId: $jobId);
|
||||
$this->handler->handle($command);
|
||||
|
||||
$job = $this->jobRepository->findByType('scraping_job');
|
||||
$this->assertCount(1, $job);
|
||||
$job = array_values($job)[0];
|
||||
$jobs = $this->jobRepository->findByType('scraping_job');
|
||||
$this->assertCount(1, $jobs);
|
||||
$job = array_values($jobs)[0];
|
||||
|
||||
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
|
||||
$this->assertCount(2, $dispatchedMessages);
|
||||
|
||||
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
|
||||
$this->assertSame($jobId, $dispatchedMessages[0]->getJobId());
|
||||
$this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber());
|
||||
|
||||
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]);
|
||||
|
||||
@@ -35,13 +35,14 @@ class ScrapeChapterTest extends AbstractApiTestCase
|
||||
// Then
|
||||
$this->assertResponseStatusCodeSame(202);
|
||||
|
||||
$messages = $this->messageBus->getDispatchedMessages();
|
||||
$messages = InMemoryMessageBus::$messages;
|
||||
$this->assertCount(1, $messages, 'Un message devrait être dispatché');
|
||||
|
||||
/** @var ScrapeChapter $message */
|
||||
$message = $messages[0];
|
||||
$this->assertInstanceOf(ScrapeChapter::class, $message);
|
||||
$this->assertEquals('chapter-123', $message->chapterId);
|
||||
$this->assertNotEmpty($message->jobId);
|
||||
}
|
||||
|
||||
public function testInitiateChapterScrapingWithInvalidPayload(): void
|
||||
@@ -72,6 +73,6 @@ class ScrapeChapterTest extends AbstractApiTestCase
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
$this->messageBus->clear();
|
||||
InMemoryMessageBus::$messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user