Compare commits
25 Commits
01b6628fa6
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9926da6730 | ||
| 4c80aa6b42 | |||
| c0307a9173 | |||
|
|
45f7e88024 | ||
| 507fac5b5e | |||
| 071e12a06c | |||
|
|
59f72339fa | ||
| 3963efa986 | |||
|
|
ca8791cc0d | ||
| c2b55e9018 | |||
|
|
07d1b2daed | ||
| a7e6879e83 | |||
|
|
fa035bfbfa | ||
|
|
ec4a8be934 | ||
| 8443120c2f | |||
| 7a8f749f3f | |||
|
|
670e3f5315 | ||
| 4398170989 | |||
|
|
fc4ab68e8b | ||
| 36f873aaca | |||
|
|
874003eb35 | ||
|
|
01474c264b | ||
|
|
795cbeccc3 | ||
| b0ce36096f | |||
|
|
da8a19cbcb |
@@ -1,13 +1,17 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { Job } from '../../domain/entities/job';
|
||||||
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
||||||
|
|
||||||
const jobRepository = new ApiJobRepository();
|
const jobRepository = new ApiJobRepository();
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = ['pending', 'in_progress'];
|
||||||
|
|
||||||
export const useActivityStore = defineStore('activity', {
|
export const useActivityStore = defineStore('activity', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
jobs: [],
|
jobs: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
mercureEventSource: null,
|
||||||
// Pagination
|
// Pagination
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
@@ -15,21 +19,15 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
hasPreviousPage: false,
|
hasPreviousPage: false,
|
||||||
// Filtres
|
// Tri
|
||||||
filter: {
|
|
||||||
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
|
|
||||||
sortBy: 'createdAt',
|
sortBy: 'createdAt',
|
||||||
sortOrder: 'DESC'
|
sortOrder: 'DESC',
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
activeJobs: state => state.jobs.filter(job => job.isActive()),
|
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,
|
isLoading: state => state.loading,
|
||||||
hasError: state => !!state.error,
|
hasError: state => !!state.error,
|
||||||
// Getters pour la pagination
|
|
||||||
paginationInfo: state => ({
|
paginationInfo: state => ({
|
||||||
currentPage: state.currentPage,
|
currentPage: state.currentPage,
|
||||||
totalPages: state.totalPages,
|
totalPages: state.totalPages,
|
||||||
@@ -41,44 +39,25 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
/**
|
|
||||||
* Charge la liste des jobs selon les filtres actuels
|
|
||||||
* @param {number} page - Numéro de page optionnel
|
|
||||||
*/
|
|
||||||
async loadJobs(page = null) {
|
async loadJobs(page = null) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options = {
|
const jobCollection = await jobRepository.getJobs({
|
||||||
page: page || this.currentPage,
|
page: page || this.currentPage,
|
||||||
limit: this.limit,
|
limit: this.limit,
|
||||||
sortBy: this.filter.sortBy,
|
sortBy: this.sortBy,
|
||||||
sortOrder: this.filter.sortOrder,
|
sortOrder: this.sortOrder,
|
||||||
status: this.filter.status
|
status: ACTIVE_STATUSES,
|
||||||
};
|
});
|
||||||
|
|
||||||
const jobCollection = await jobRepository.getJobs(options);
|
|
||||||
|
|
||||||
// Mettre à jour les données
|
|
||||||
this.jobs = jobCollection.items;
|
this.jobs = jobCollection.items;
|
||||||
this.currentPage = jobCollection.page;
|
this.currentPage = jobCollection.page;
|
||||||
this.total = jobCollection.total;
|
this.total = jobCollection.total;
|
||||||
this.hasNextPage = jobCollection.hasNextPage;
|
this.hasNextPage = jobCollection.hasNextPage;
|
||||||
this.hasPreviousPage = jobCollection.hasPreviousPage;
|
this.hasPreviousPage = jobCollection.hasPreviousPage;
|
||||||
|
|
||||||
// Calculer le nombre total de pages
|
|
||||||
this.totalPages = Math.ceil(this.total / this.limit);
|
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) {
|
} catch (error) {
|
||||||
this.error = error.message;
|
this.error = error.message;
|
||||||
console.error('Error loading jobs:', error);
|
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) {
|
async goToPage(page) {
|
||||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||||
this.currentPage = page;
|
this.currentPage = page;
|
||||||
@@ -98,39 +73,26 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
async updateSort(sortBy, sortOrder) {
|
||||||
* Met à jour les filtres et recharge la liste
|
this.sortBy = sortBy;
|
||||||
* @param {Object} filter
|
this.sortOrder = sortOrder;
|
||||||
*/
|
this.currentPage = 1;
|
||||||
async updateFilter(filter) {
|
|
||||||
this.filter = { ...this.filter, ...filter };
|
|
||||||
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
|
|
||||||
await this.loadJobs(1);
|
await this.loadJobs(1);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Met à jour la limite par page
|
|
||||||
* @param {number} limit
|
|
||||||
*/
|
|
||||||
async updateLimit(limit) {
|
async updateLimit(limit) {
|
||||||
this.limit = limit;
|
this.limit = limit;
|
||||||
this.currentPage = 1; // Retourner à la première page
|
this.currentPage = 1;
|
||||||
await this.loadJobs(1);
|
await this.loadJobs(1);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime un job par son ID
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
async deleteJob(id) {
|
async deleteJob(id) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await jobRepository.deleteJob(id);
|
await jobRepository.deleteJob(id);
|
||||||
// Supprimer le job de la liste locale
|
|
||||||
this.jobs = this.jobs.filter(job => job.id !== id);
|
this.jobs = this.jobs.filter(job => job.id !== id);
|
||||||
// Recharger la page courante pour avoir les bons totaux
|
|
||||||
await this.loadJobs(this.currentPage);
|
await this.loadJobs(this.currentPage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message;
|
this.error = error.message;
|
||||||
@@ -140,17 +102,75 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
updateJobProgress(jobId, progress) {
|
||||||
* Supprime tous les jobs correspondant aux critères
|
const job = this.jobs.find(j => j.id === jobId);
|
||||||
* @param {Object} criteria
|
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 = {}) {
|
async deleteJobs(criteria = {}) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deleted = await jobRepository.deleteJobs(criteria);
|
const deleted = await jobRepository.deleteJobs(criteria);
|
||||||
// Recharger la liste après suppression
|
|
||||||
await this.loadJobs(this.currentPage);
|
await this.loadJobs(this.currentPage);
|
||||||
return deleted;
|
return deleted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -160,26 +180,5 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
this.loading = false;
|
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({});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export class Job {
|
|||||||
failureReason = null,
|
failureReason = null,
|
||||||
createdAt = new Date().toISOString(),
|
createdAt = new Date().toISOString(),
|
||||||
updatedAt = new Date().toISOString(),
|
updatedAt = new Date().toISOString(),
|
||||||
|
startedAt = null,
|
||||||
|
completedAt = null,
|
||||||
attempts = 0,
|
attempts = 0,
|
||||||
maxAttempts = 1,
|
maxAttempts = 1,
|
||||||
context = {}
|
context = {}
|
||||||
@@ -23,6 +25,8 @@ export class Job {
|
|||||||
this.error = failureReason ?? error;
|
this.error = failureReason ?? error;
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
this.completedAt = completedAt;
|
||||||
this.attempts = attempts;
|
this.attempts = attempts;
|
||||||
this.maxAttempts = maxAttempts;
|
this.maxAttempts = maxAttempts;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
* @returns {Promise<JobCollection>} Collection de jobs
|
* @returns {Promise<JobCollection>} Collection de jobs
|
||||||
*/
|
*/
|
||||||
async getJobs(options = {}) {
|
async getJobs(options = {}) {
|
||||||
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options;
|
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [], type = null } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
||||||
@@ -23,6 +23,11 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
url += `&status=${status.join(',')}`;
|
url += `&status=${status.join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ajouter le filtre de type si fourni
|
||||||
|
if (type) {
|
||||||
|
url += `&type=${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,155 +1,143 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto h-full">
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
<!-- 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>
|
||||||
|
|
||||||
<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">
|
<!-- Error -->
|
||||||
<p>{{ activityStore.error }}</p>
|
<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>
|
||||||
|
|
||||||
<div v-else class="container mx-auto p-2">
|
<!-- Content -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
<section v-else class="border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="overflow-x-auto">
|
<!-- Empty -->
|
||||||
<table class="min-w-full bg-white dark:bg-gray-800">
|
<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>
|
<thead>
|
||||||
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
|
<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-1/12 py-3 px-4 text-left">
|
<th class="w-2/11 py-3 px-6 text-left">Type</th>
|
||||||
<input
|
<th class="w-2/11 py-3 px-4 text-left">Statut</th>
|
||||||
type="checkbox"
|
<th class="w-3/11 py-3 px-4 text-left">Informations</th>
|
||||||
class="form-checkbox h-5 w-5 text-green-600"
|
<th class="w-3/11 py-3 px-4 text-left">Progression</th>
|
||||||
@change="toggleSelectAll" />
|
<th class="w-1/11 py-3 px-4 text-left">Actions</th>
|
||||||
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="text-gray-700 dark:text-gray-300">
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 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
|
<JobItem
|
||||||
v-for="job in activityStore.jobs"
|
v-for="job in activityStore.jobs"
|
||||||
:key="job.id"
|
:key="job.id"
|
||||||
:job="job"
|
:job="job"
|
||||||
@delete="deleteJob" />
|
@delete="deleteJob" />
|
||||||
</template>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="activityStore.total > activityStore.limit"
|
v-if="total > activityStore.limit"
|
||||||
:current-page="activityStore.currentPage"
|
:current-page="activityStore.currentPage"
|
||||||
:total-pages="activityStore.totalPages"
|
:total-pages="activityStore.totalPages"
|
||||||
:total="activityStore.total"
|
:total="total"
|
||||||
:limit="activityStore.limit"
|
:limit="activityStore.limit"
|
||||||
:has-next-page="activityStore.hasNextPage"
|
:has-next-page="activityStore.hasNextPage"
|
||||||
:has-previous-page="activityStore.hasPreviousPage"
|
:has-previous-page="activityStore.hasPreviousPage"
|
||||||
@page-change="changePage" />
|
@page-change="changePage" />
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue';
|
||||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useActivityStore } from '../../application/store/activityStore';
|
import { useActivityStore } from '../../application/store/activityStore';
|
||||||
import JobItem from '../components/JobItem.vue';
|
import JobItem from '../components/JobItem.vue';
|
||||||
|
|
||||||
const activityStore = useActivityStore();
|
const activityStore = useActivityStore();
|
||||||
const selectedAll = ref(false);
|
|
||||||
|
|
||||||
// Statuts disponibles pour le filtre
|
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
|
||||||
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' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Index du statut actif (par défaut "Actifs")
|
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||||
const activeStatusIndex = ref(0);
|
|
||||||
|
|
||||||
// Configuration de la toolbar réactive
|
|
||||||
const toolbarConfig = computed(() => ({
|
const toolbarConfig = computed(() => ({
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{
|
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' },
|
||||||
icon: FunnelIcon,
|
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||||
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: [
|
rightSection: [
|
||||||
{
|
{
|
||||||
icon: ArrowPathIcon,
|
type: 'dropdown',
|
||||||
type: 'button',
|
icon: BarsArrowDownIcon,
|
||||||
label: 'Rafraîchir',
|
label: 'Trier',
|
||||||
onClick: refreshJobs
|
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'),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: TrashIcon,
|
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Rafraîchir',
|
||||||
|
disabled: loading.value,
|
||||||
|
onClick: () => activityStore.loadJobs(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: TrashIcon,
|
||||||
label: 'Supprimer visibles',
|
label: 'Supprimer visibles',
|
||||||
onClick: deleteVisibleJobs
|
disabled: loading.value || total.value === 0,
|
||||||
}
|
onClick: deleteVisibleJobs,
|
||||||
]
|
},
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadJobs();
|
activityStore.loadJobs();
|
||||||
|
activityStore.subscribeMercure();
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadJobs() {
|
onUnmounted(() => {
|
||||||
activityStore.loadJobs();
|
activityStore.unsubscribeMercure();
|
||||||
}
|
});
|
||||||
|
|
||||||
function refreshJobs() {
|
|
||||||
loadJobs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePage(page) {
|
function changePage(page) {
|
||||||
activityStore.goToPage(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) {
|
function deleteJob(id) {
|
||||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||||
activityStore.deleteJob(id);
|
activityStore.deleteJob(id);
|
||||||
@@ -157,13 +145,9 @@ import JobItem from '../components/JobItem.vue';
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteVisibleJobs() {
|
function deleteVisibleJobs() {
|
||||||
if (activityStore.jobs.length === 0) {
|
if (activityStore.jobs.length === 0) return;
|
||||||
return;
|
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
|
||||||
}
|
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
|
||||||
|
|
||||||
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
|
|
||||||
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
|
|
||||||
activityStore.deleteJobs({ status: activityStore.filter.status });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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 getPlaceholderHeight = (page) => {
|
||||||
const dims = page?.dimensions;
|
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
|
const displayWidth = windowWidth.value < 1200
|
||||||
? Math.min(dims.width, windowWidth.value * 0.95)
|
? Math.min(dims.width, windowWidth.value * 0.95)
|
||||||
: Math.min(dims.width, 1200);
|
: 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 = () => {
|
const setupObservers = () => {
|
||||||
@@ -109,7 +109,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
visibilityObserver.value?.disconnect();
|
visibilityObserver.value?.disconnect();
|
||||||
|
|
||||||
observer.value = new IntersectionObserver(observeIntersection, {
|
observer.value = new IntersectionObserver(observeIntersection, {
|
||||||
root: null,
|
root: containerRef.value,
|
||||||
threshold: 0.5
|
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(() => {
|
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;
|
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
||||||
/* Réduction du padding sur mobile */
|
/* Réduction du padding sur mobile */
|
||||||
@apply py-2 sm:py-8;
|
@apply py-2 sm:py-8;
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
|
|||||||
@@ -87,13 +87,9 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
|
|
||||||
const store = useReaderStore();
|
const store = useReaderStore();
|
||||||
|
|
||||||
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
|
// zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll)
|
||||||
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
|
|
||||||
const containerStyle = computed(() => {
|
const containerStyle = computed(() => {
|
||||||
if (store.readingMode === 'single') {
|
|
||||||
return { zoom: props.zoom };
|
return { zoom: props.zoom };
|
||||||
}
|
|
||||||
return { transform: `scale(${props.zoom})` };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageRef = ref(null);
|
const imageRef = ref(null);
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ export const useContentSourceStore = defineStore('contentSource', {
|
|||||||
importing: false,
|
importing: false,
|
||||||
exporting: false,
|
exporting: false,
|
||||||
importError: null,
|
importError: null,
|
||||||
exportError: null
|
exportError: null,
|
||||||
|
|
||||||
|
// Health check state
|
||||||
|
checkingHealth: false,
|
||||||
|
checkHealthError: null,
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
deleting: false,
|
||||||
|
deleteError: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -168,12 +176,64 @@ export const useContentSourceStore = defineStore('contentSource', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Delete a source
|
||||||
|
async deleteSource(id) {
|
||||||
|
if (this.deleting) return;
|
||||||
|
|
||||||
|
this.deleting = true;
|
||||||
|
this.deleteError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contentSourceRepository.delete(id);
|
||||||
|
this.sources = this.sources.filter(source => source.id !== id);
|
||||||
|
if (this.currentSource && this.currentSource.id === id) {
|
||||||
|
this.currentSource = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.deleteError = error.message;
|
||||||
|
console.error('Erreur lors de la suppression de la source:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.deleting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Clear current source
|
// Clear current source
|
||||||
clearCurrentSource() {
|
clearCurrentSource() {
|
||||||
this.currentSource = null;
|
this.currentSource = null;
|
||||||
this.currentSourceError = null;
|
this.currentSourceError = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Check all scrapers health
|
||||||
|
async checkAllHealth() {
|
||||||
|
if (this.checkingHealth) return;
|
||||||
|
|
||||||
|
this.checkingHealth = true;
|
||||||
|
this.checkHealthError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contentSourceRepository.checkAllHealth();
|
||||||
|
} catch (error) {
|
||||||
|
this.checkHealthError = error.message;
|
||||||
|
console.error('Erreur lors du health check:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.checkingHealth = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update health status of a single source (called from Mercure)
|
||||||
|
updateSourceHealth(sourceId, status, error = null) {
|
||||||
|
const index = this.sources.findIndex(s => s.id === sourceId);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.sources[index] = {
|
||||||
|
...this.sources[index],
|
||||||
|
healthStatus: status,
|
||||||
|
healthLastError: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Clear errors
|
// Clear errors
|
||||||
clearErrors() {
|
clearErrors() {
|
||||||
this.sourcesError = null;
|
this.sourcesError = null;
|
||||||
@@ -181,6 +241,7 @@ export const useContentSourceStore = defineStore('contentSource', {
|
|||||||
this.saveError = null;
|
this.saveError = null;
|
||||||
this.importError = null;
|
this.importError = null;
|
||||||
this.exportError = null;
|
this.exportError = null;
|
||||||
|
this.checkHealthError = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const ScraperHealthStatus = {
|
||||||
|
UNKNOWN: 'unknown',
|
||||||
|
OK: 'ok',
|
||||||
|
KO: 'ko',
|
||||||
|
TESTING: 'testing',
|
||||||
|
};
|
||||||
@@ -82,6 +82,28 @@ export class ApiContentSourceRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclenche le test de santé de tous les scrapers
|
||||||
|
*/
|
||||||
|
async checkAllHealth() {
|
||||||
|
try {
|
||||||
|
await this.apiClient.post('/scraping/check-all-health', {});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors du lancement du health check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une source de contenu
|
||||||
|
*/
|
||||||
|
async delete(id) {
|
||||||
|
try {
|
||||||
|
await this.apiClient.delete(`/content-sources/${id}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de la suppression de la source');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teste une configuration de scraper
|
* Teste une configuration de scraper
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
@click="$emit('edit', source)"
|
@click="$emit('edit', source)"
|
||||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
|
class="bg-white dark:bg-gray-800 shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
|
||||||
<!-- Header avec URL et icône externe -->
|
<!-- Header avec URL et icône externe -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl">
|
||||||
@@ -20,16 +20,24 @@
|
|||||||
<!-- Badge type de scraping -->
|
<!-- Badge type de scraping -->
|
||||||
<span
|
<span
|
||||||
:class="getScrapingTypeBadgeClass(source.scrapingType)"
|
:class="getScrapingTypeBadgeClass(source.scrapingType)"
|
||||||
class="px-2 py-1 text-xs font-medium rounded-md">
|
class="px-2 py-1 text-xs font-medium">
|
||||||
{{ source.scrapingType?.toLowerCase() || 'N/A' }}
|
{{ source.scrapingType?.toLowerCase() || 'N/A' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Badge orientation basé sur les sélecteurs -->
|
<!-- Badge orientation basé sur les sélecteurs -->
|
||||||
<span
|
<span
|
||||||
:class="getOrientationBadgeClass(source)"
|
:class="getOrientationBadgeClass(source)"
|
||||||
class="px-2 py-1 text-xs font-medium rounded-md">
|
class="px-2 py-1 text-xs font-medium">
|
||||||
{{ getOrientation(source) }}
|
{{ getOrientation(source) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Badge health status -->
|
||||||
|
<span
|
||||||
|
:class="getHealthBadgeClass(source.healthStatus)"
|
||||||
|
class="px-2 py-1 text-xs font-medium"
|
||||||
|
:title="source.healthLastError || ''">
|
||||||
|
{{ getHealthLabel(source.healthStatus) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +47,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { ScraperHealthStatus } from '../../domain/model/ScraperHealthStatus';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
source: {
|
source: {
|
||||||
@@ -86,4 +95,26 @@ const getOrientationBadgeClass = (source) => {
|
|||||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHealthLabel = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case ScraperHealthStatus.OK: return '✓ ok';
|
||||||
|
case ScraperHealthStatus.KO: return '✗ ko';
|
||||||
|
case ScraperHealthStatus.TESTING: return '⟳ test';
|
||||||
|
default: return '? unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHealthBadgeClass = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case ScraperHealthStatus.OK:
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
case ScraperHealthStatus.KO:
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
case ScraperHealthStatus.TESTING:
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<TransitionRoot as="template" :show="isOpen">
|
||||||
|
<Dialog as="div" class="relative z-50" @close="closeModal">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enter-to="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||||
|
<div class="mb-6">
|
||||||
|
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||||
|
Supprimer la source de contenu
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning message -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Êtes-vous sûr de vouloir supprimer la source <strong>{{ source?.baseUrl }}</strong> ?
|
||||||
|
</p>
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
||||||
|
Attention
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
<p>Cette source ne pourra plus être utilisée pour le scraping des chapitres.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="mt-6 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
@click="closeModal"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
@click="confirmDelete"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{{ isLoading ? 'Suppression...' : 'Supprimer définitivement' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||||
|
import { ArrowPathIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
emit('confirm');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
<div>
|
||||||
<!-- Header -->
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600 rounded-t-lg">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Cog6ToothIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ isEditing ? 'Edit Scrapper Configuration' : 'New Scrapper Configuration' }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-6">
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
<!-- Base URL -->
|
<!-- Base URL -->
|
||||||
<div>
|
<div>
|
||||||
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
@@ -22,25 +12,12 @@
|
|||||||
v-model="form.baseUrl"
|
v-model="form.baseUrl"
|
||||||
type="url"
|
type="url"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="https://example.com" />
|
placeholder="https://example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image Selector -->
|
|
||||||
<div>
|
|
||||||
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Image Selector
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="imageSelector"
|
|
||||||
v-model="form.imageSelector"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder=".reading-content .page-break img" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chapter URL Format -->
|
<!-- Chapter URL Format -->
|
||||||
<div>
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
|
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -49,37 +26,51 @@
|
|||||||
v-model="form.chapterUrlFormat"
|
v-model="form.chapterUrlFormat"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
|
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Next Page Selector -->
|
<!-- Selectors -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Image Selector
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="imageSelector"
|
||||||
|
v-model="form.imageSelector"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".reading-content .page-break img" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Next Page Selector <span class="text-gray-500">(let empty if vertical reader)</span>
|
Next Page Selector <span class="text-gray-500">(laisser vide si lecteur vertical)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="nextPageSelector"
|
id="nextPageSelector"
|
||||||
v-model="form.nextPageSelector"
|
v-model="form.nextPageSelector"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder=".next-page" />
|
placeholder=".next-page" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chapter Selector -->
|
|
||||||
<div>
|
<div>
|
||||||
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Chapter Selector <span class="text-gray-500">(required for Javascript scraping)</span>
|
Chapter Selector <span class="text-gray-500">(requis pour le scraping Javascript)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="chapterSelector"
|
id="chapterSelector"
|
||||||
v-model="form.chapterSelector"
|
v-model="form.chapterSelector"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder=".chapter-selector" />
|
placeholder=".chapter-selector" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scraping Type -->
|
<!-- Scraping Type + Token -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Scraping Type
|
Scraping Type
|
||||||
@@ -88,13 +79,12 @@
|
|||||||
id="scrapingType"
|
id="scrapingType"
|
||||||
v-model="form.scrapingType"
|
v-model="form.scrapingType"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
|
||||||
<option value="html">HTML</option>
|
<option value="html">HTML</option>
|
||||||
<option value="javascript">Javascript</option>
|
<option value="javascript">Javascript</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token (optionnel) -->
|
|
||||||
<div>
|
<div>
|
||||||
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Token
|
Token
|
||||||
@@ -103,78 +93,65 @@
|
|||||||
id="token"
|
id="token"
|
||||||
v-model="form.token"
|
v-model="form.token"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="Optional authentication token" />
|
placeholder="Optional authentication token" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="saving"
|
|
||||||
class="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center space-x-2">
|
|
||||||
<ArrowPathIcon v-if="saving" class="w-4 h-4 animate-spin" />
|
|
||||||
<span>{{ isEditing ? 'Update Configuration' : 'Create Configuration' }}</span>
|
|
||||||
<PencilSquareIcon v-if="!saving" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error message -->
|
<!-- Error message -->
|
||||||
<div v-if="error" class="text-red-600 dark:text-red-400 text-sm">
|
<div v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 text-red-600 dark:text-red-400 text-sm">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Test Configuration Section -->
|
<!-- Test Configuration Section -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-600 p-6 bg-gray-50 dark:bg-gray-700 rounded-b-lg">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<div class="flex items-center space-x-2 mb-4">
|
<div class="flex items-center space-x-2 mb-6">
|
||||||
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Test Configuration</h3>
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Configuration de test (health check)</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="testSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Manga Slug
|
Manga Slug <span class="text-gray-500">(enregistré)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="testMangaSlug"
|
id="testSlug"
|
||||||
v-model="testData.mangaSlug"
|
v-model="form.testSlug"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="manga-slug" />
|
placeholder="manga-slug" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Chapter Number
|
Numéro de chapitre <span class="text-gray-500">(enregistré)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="testChapterNumber"
|
id="testChapterNumber"
|
||||||
v-model="testData.chapterNumber"
|
v-model="form.testChapterNumber"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="1" />
|
placeholder="1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview de l'URL qui sera testée -->
|
<!-- Preview URL -->
|
||||||
<div v-if="generatedTestUrl" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md">
|
<div v-if="generatedTestUrl" class="mb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">URL qui sera testée</p>
|
||||||
<strong>URL qui sera testée :</strong>
|
<code class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ generatedTestUrl }}</code>
|
||||||
<div class="mt-1 font-mono text-xs break-all">{{ generatedTestUrl }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="testConfiguration"
|
@click="testConfiguration"
|
||||||
:disabled="testing || !canTest"
|
:disabled="testing || !canTest"
|
||||||
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center space-x-2">
|
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200 flex items-center justify-center space-x-2">
|
||||||
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
|
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
|
||||||
<PlayIcon v-else class="w-4 h-4" />
|
<PlayIcon v-else class="w-4 h-4" />
|
||||||
<span>Test Configuration</span>
|
<span>Tester maintenant</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,8 +160,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
Cog6ToothIcon,
|
|
||||||
PencilSquareIcon,
|
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
WrenchScrewdriverIcon
|
WrenchScrewdriverIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
@@ -216,12 +191,9 @@ const form = ref({
|
|||||||
nextPageSelector: '',
|
nextPageSelector: '',
|
||||||
chapterSelector: '',
|
chapterSelector: '',
|
||||||
scrapingType: 'html',
|
scrapingType: 'html',
|
||||||
token: ''
|
token: '',
|
||||||
});
|
testSlug: '',
|
||||||
|
testChapterNumber: '',
|
||||||
const testData = ref({
|
|
||||||
mangaSlug: '',
|
|
||||||
chapterNumber: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const testing = ref(false);
|
const testing = ref(false);
|
||||||
@@ -229,20 +201,19 @@ const testing = ref(false);
|
|||||||
const canTest = computed(() => {
|
const canTest = computed(() => {
|
||||||
return form.value.baseUrl &&
|
return form.value.baseUrl &&
|
||||||
form.value.chapterUrlFormat &&
|
form.value.chapterUrlFormat &&
|
||||||
testData.value.mangaSlug &&
|
form.value.testSlug &&
|
||||||
testData.value.chapterNumber;
|
form.value.testChapterNumber;
|
||||||
});
|
});
|
||||||
|
|
||||||
const generatedTestUrl = computed(() => {
|
const generatedTestUrl = computed(() => {
|
||||||
if (!form.value.chapterUrlFormat || !testData.value.mangaSlug || !testData.value.chapterNumber) {
|
if (!form.value.chapterUrlFormat || !form.value.testSlug || !form.value.testChapterNumber) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return form.value.chapterUrlFormat
|
return form.value.chapterUrlFormat
|
||||||
.replace('{slug}', testData.value.mangaSlug)
|
.replace('{slug}', form.value.testSlug)
|
||||||
.replace('{chapterNumber}', testData.value.chapterNumber);
|
.replace('{chapterNumber}', form.value.testChapterNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize form with source data if editing, clear if creating new
|
|
||||||
watch(() => props.source, (newSource) => {
|
watch(() => props.source, (newSource) => {
|
||||||
if (newSource) {
|
if (newSource) {
|
||||||
form.value = {
|
form.value = {
|
||||||
@@ -252,10 +223,11 @@ watch(() => props.source, (newSource) => {
|
|||||||
nextPageSelector: newSource.nextPageSelector || '',
|
nextPageSelector: newSource.nextPageSelector || '',
|
||||||
chapterSelector: newSource.chapterSelector || '',
|
chapterSelector: newSource.chapterSelector || '',
|
||||||
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
|
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
|
||||||
token: newSource.token || ''
|
token: newSource.token || '',
|
||||||
|
testSlug: newSource.testSlug || '',
|
||||||
|
testChapterNumber: newSource.testChapterNumber ?? '',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Reset form when no source (creating new)
|
|
||||||
form.value = {
|
form.value = {
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
imageSelector: '',
|
imageSelector: '',
|
||||||
@@ -263,7 +235,9 @@ watch(() => props.source, (newSource) => {
|
|||||||
nextPageSelector: '',
|
nextPageSelector: '',
|
||||||
chapterSelector: '',
|
chapterSelector: '',
|
||||||
scrapingType: 'html',
|
scrapingType: 'html',
|
||||||
token: ''
|
token: '',
|
||||||
|
testSlug: '',
|
||||||
|
testChapterNumber: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
@@ -272,14 +246,17 @@ const handleSubmit = () => {
|
|||||||
emit('submit', { ...form.value });
|
emit('submit', { ...form.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defineExpose({ submitForm: handleSubmit });
|
||||||
|
|
||||||
const testConfiguration = async () => {
|
const testConfiguration = async () => {
|
||||||
testing.value = true;
|
testing.value = true;
|
||||||
try {
|
try {
|
||||||
await emit('test', {
|
await emit('test', {
|
||||||
configuration: { ...form.value },
|
configuration: { ...form.value },
|
||||||
testData: {
|
testData: {
|
||||||
...testData.value,
|
mangaSlug: form.value.testSlug,
|
||||||
testUrl: generatedTestUrl.value
|
chapterNumber: form.value.testChapterNumber,
|
||||||
|
testUrl: generatedTestUrl.value,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,47 +3,28 @@
|
|||||||
<Toolbar :config="toolbarConfig" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div class="overflow-y-auto flex-1">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="px-6 py-8">
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
Scrapper Configurations
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Gérez les configurations de scraping pour les différentes sources de manga
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loadingSources" class="flex justify-center py-12">
|
<div v-if="loadingSources" class="flex justify-center py-12">
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||||
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
|
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="contentSourceStore.loadSources()"
|
@click="contentSourceStore.loadSources()"
|
||||||
class="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
|
||||||
Réessayer
|
Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug Info (temporary) -->
|
|
||||||
<div v-if="!loadingSources && !sourcesError && sources.length === 0" class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
|
|
||||||
<p class="text-blue-800 dark:text-blue-200">Aucune source trouvée. Rechargement en cours...</p>
|
|
||||||
<button
|
|
||||||
@click="contentSourceStore.loadSources()"
|
|
||||||
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
||||||
Actualiser
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sources Grid -->
|
<!-- Sources Grid -->
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<section v-else class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- Existing Sources -->
|
<!-- Existing Sources -->
|
||||||
<ContentSourceCard
|
<ContentSourceCard
|
||||||
v-for="source in sources"
|
v-for="source in sources"
|
||||||
@@ -55,20 +36,21 @@
|
|||||||
<!-- Add New Configuration Card -->
|
<!-- Add New Configuration Card -->
|
||||||
<div
|
<div
|
||||||
@click="addNewSource"
|
@click="addNewSource"
|
||||||
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
|
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
|
||||||
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
|
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
|
||||||
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
|
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
Add New Configuration
|
Add New Configuration
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Import/Export Success Messages -->
|
<!-- Import/Export Success Messages -->
|
||||||
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
|
||||||
Configuration importée avec succès !
|
Configuration importée avec succès !
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 shadow-lg">
|
||||||
Configuration exportée !
|
Configuration exportée !
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +58,12 @@
|
|||||||
|
|
||||||
<!-- Import Modal -->
|
<!-- Import Modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
<div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-md">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
|
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="importData"
|
v-model="importData"
|
||||||
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="Collez ici le JSON des configurations à importer..."></textarea>
|
placeholder="Collez ici le JSON des configurations à importer..."></textarea>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 mt-4">
|
<div class="flex justify-end space-x-3 mt-4">
|
||||||
@@ -93,7 +75,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="handleImport"
|
@click="handleImport"
|
||||||
:disabled="importing || !importData.trim()"
|
:disabled="importing || !importData.trim()"
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md">
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white">
|
||||||
{{ importing ? 'Import...' : 'Importer' }}
|
{{ importing ? 'Import...' : 'Importer' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,10 +91,11 @@ import {
|
|||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
ArrowUpTrayIcon,
|
ArrowUpTrayIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
HeartIcon,
|
||||||
PlusIcon
|
PlusIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||||
@@ -126,9 +109,13 @@ const {
|
|||||||
loadingSources,
|
loadingSources,
|
||||||
sourcesError,
|
sourcesError,
|
||||||
importing,
|
importing,
|
||||||
exporting
|
exporting,
|
||||||
|
checkingHealth,
|
||||||
} = storeToRefs(contentSourceStore);
|
} = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
// Mercure — écoute des mises à jour health
|
||||||
|
let mercureEventSource = null;
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const showImportModal = ref(false);
|
const showImportModal = ref(false);
|
||||||
const showExportSuccess = ref(false);
|
const showExportSuccess = ref(false);
|
||||||
@@ -138,40 +125,45 @@ const importData = ref('');
|
|||||||
// Load sources on mount and clear current source
|
// Load sources on mount and clear current source
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
contentSourceStore.clearCurrentSource(); // Clear any previously loaded source
|
contentSourceStore.clearCurrentSource();
|
||||||
contentSourceStore.clearErrors(); // Clear any previous errors
|
contentSourceStore.clearErrors();
|
||||||
await contentSourceStore.loadSources();
|
await contentSourceStore.loadSources();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des sources:', error);
|
console.error('Erreur lors du chargement des sources:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Écoute Mercure pour les mises à jour de health status
|
||||||
|
const url = new URL('/.well-known/mercure', window.location.href);
|
||||||
|
sources.value.forEach(source => {
|
||||||
|
url.searchParams.append('topic', `scrapers/health/${source.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mercureEventSource = new EventSource(url.toString());
|
||||||
|
mercureEventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
contentSourceStore.updateSourceHealth(data.sourceId, data.status, data.error);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur parsing Mercure event:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
mercureEventSource?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toolbar configuration
|
// Toolbar configuration
|
||||||
const toolbarConfig = computed(() => ({
|
const toolbarConfig = computed(() => ({
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{
|
{ type: 'label', text: 'Scrapers', class: 'text-sm font-medium' },
|
||||||
icon: ArrowPathIcon,
|
|
||||||
label: 'Actualiser',
|
|
||||||
type: 'button',
|
|
||||||
onClick: () => contentSourceStore.loadSources(),
|
|
||||||
active: loadingSources.value
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
rightSection: [
|
rightSection: [
|
||||||
{
|
{ type: 'button', icon: ArrowPathIcon, label: 'Actualiser', onClick: () => contentSourceStore.loadSources(), disabled: loadingSources.value },
|
||||||
icon: ArrowDownTrayIcon,
|
{ type: 'button', icon: HeartIcon, label: 'Tester tous', onClick: handleCheckAllHealth, disabled: checkingHealth.value },
|
||||||
label: 'Exporter',
|
{ type: 'button', icon: ArrowDownTrayIcon, label: 'Exporter', onClick: handleExport, disabled: exporting.value },
|
||||||
type: 'button',
|
{ type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
|
||||||
onClick: handleExport,
|
],
|
||||||
disabled: exporting.value
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ArrowUpTrayIcon,
|
|
||||||
label: 'Importer',
|
|
||||||
type: 'button',
|
|
||||||
onClick: () => showImportModal.value = true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -190,6 +182,14 @@ const openSourceLink = (url) => {
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleCheckAllHealth() {
|
||||||
|
try {
|
||||||
|
await contentSourceStore.checkAllHealth();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du health check:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
try {
|
try {
|
||||||
const exportData = await contentSourceStore.exportSources();
|
const exportData = await contentSourceStore.exportSources();
|
||||||
|
|||||||
@@ -3,24 +3,15 @@
|
|||||||
<Toolbar :config="toolbarConfig" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div class="overflow-y-auto flex-1">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="px-6 py-8">
|
||||||
<!-- Back Navigation -->
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<div class="mb-6">
|
|
||||||
<button
|
|
||||||
@click="goBack"
|
|
||||||
class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
|
||||||
<ArrowLeftIcon class="w-5 h-5" />
|
|
||||||
<span>Retour aux configurations</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||||
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
||||||
@@ -28,18 +19,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<div v-else class="max-w-4xl mx-auto">
|
<div v-else>
|
||||||
<ContentSourceForm
|
<ContentSourceForm
|
||||||
|
ref="formRef"
|
||||||
:source="currentSource"
|
:source="currentSource"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:error="saveError"
|
:error="saveError"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@test="handleTest" />
|
@test="handleTest" />
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Test Results Modal -->
|
<!-- Test Results Modal -->
|
||||||
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="text-lg font-semibold">Résultats du test</h3>
|
<h3 class="text-lg font-semibold">Résultats du test</h3>
|
||||||
@@ -54,7 +47,7 @@
|
|||||||
<div class="p-6 overflow-y-auto">
|
<div class="p-6 overflow-y-auto">
|
||||||
<!-- Loading state during test -->
|
<!-- Loading state during test -->
|
||||||
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
|
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
<div class="animate-spin h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||||
<span class="text-gray-600">Test en cours...</span>
|
<span class="text-gray-600">Test en cours...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,7 +58,7 @@
|
|||||||
<span class="font-medium">Test réussi !</span>
|
<span class="font-medium">Test réussi !</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4">
|
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 p-4">
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
|
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
|
||||||
@@ -92,10 +85,11 @@
|
|||||||
<img
|
<img
|
||||||
:src="imageUrl"
|
:src="imageUrl"
|
||||||
:alt="`Image ${index + 1}`"
|
:alt="`Image ${index + 1}`"
|
||||||
class="w-full h-32 object-cover rounded border border-gray-200 dark:border-gray-600"
|
class="w-full h-32 object-cover border border-gray-200 dark:border-gray-600"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
@load="handleImageLoad" />
|
@load="handleImageLoad" />
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity rounded flex items-center justify-center">
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
|
||||||
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
|
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
|
||||||
Page {{ index + 1 }}
|
Page {{ index + 1 }}
|
||||||
</span>
|
</span>
|
||||||
@@ -107,7 +101,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 p-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
|
||||||
<p class="text-yellow-800 dark:text-yellow-200">
|
<p class="text-yellow-800 dark:text-yellow-200">
|
||||||
@@ -125,7 +119,7 @@
|
|||||||
<span class="font-medium">Test échoué</span>
|
<span class="font-medium">Test échoué</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-4">
|
||||||
<div class="text-sm text-red-800 dark:text-red-200">
|
<div class="text-sm text-red-800 dark:text-red-200">
|
||||||
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
|
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
|
||||||
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
|
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
|
||||||
@@ -138,14 +132,14 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(error, index) in testResults.errors"
|
v-for="(error, index) in testResults.errors"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4 rounded">
|
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4">
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex-1">
|
<div class="ml-3 flex-1">
|
||||||
<div class="flex items-center mb-1">
|
<div class="flex items-center mb-1">
|
||||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
||||||
{{ formatErrorType(error.type) }}
|
{{ formatErrorType(error.type) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium text-red-800 dark:text-red-200">
|
<span class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
@@ -155,7 +149,7 @@
|
|||||||
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
|
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</p>
|
</p>
|
||||||
<div class="bg-red-50 dark:bg-red-900 rounded p-2">
|
<div class="bg-red-50 dark:bg-red-900 p-2">
|
||||||
<p class="text-xs text-red-600 dark:text-red-400">
|
<p class="text-xs text-red-600 dark:text-red-400">
|
||||||
<strong>Suggestion :</strong> {{ error.suggestion }}
|
<strong>Suggestion :</strong> {{ error.suggestion }}
|
||||||
</p>
|
</p>
|
||||||
@@ -166,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generic Error -->
|
<!-- Generic Error -->
|
||||||
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded p-3">
|
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-3">
|
||||||
<code class="text-sm text-red-800 dark:text-red-200">
|
<code class="text-sm text-red-800 dark:text-red-200">
|
||||||
{{ testResults.error }}
|
{{ testResults.error }}
|
||||||
</code>
|
</code>
|
||||||
@@ -177,11 +171,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
|
||||||
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Modal -->
|
||||||
|
<ContentSourceDeleteModal
|
||||||
|
:is-open="isDeleteModalOpen"
|
||||||
|
:source="currentSource"
|
||||||
|
:is-loading="isDeleting"
|
||||||
|
:error="deleteError"
|
||||||
|
@close="isDeleteModalOpen = false"
|
||||||
|
@confirm="confirmDeleteSource" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -190,6 +193,8 @@ import {
|
|||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
TrashIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
XMarkIcon
|
XMarkIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
@@ -199,6 +204,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||||
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
||||||
|
import ContentSourceDeleteModal from '../components/ContentSourceDeleteModal.vue';
|
||||||
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -214,11 +220,17 @@ const {
|
|||||||
saveError
|
saveError
|
||||||
} = storeToRefs(contentSourceStore);
|
} = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
// Form ref
|
||||||
|
const formRef = ref(null);
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const showTestResults = ref(false);
|
const showTestResults = ref(false);
|
||||||
const showSuccessMessage = ref(false);
|
const showSuccessMessage = ref(false);
|
||||||
const testResults = ref({});
|
const testResults = ref({});
|
||||||
const testingConfiguration = ref(false);
|
const testingConfiguration = ref(false);
|
||||||
|
const isDeleteModalOpen = ref(false);
|
||||||
|
const isDeleting = ref(false);
|
||||||
|
const deleteError = ref(null);
|
||||||
|
|
||||||
const isEditing = computed(() => !!route.params.id);
|
const isEditing = computed(() => !!route.params.id);
|
||||||
|
|
||||||
@@ -233,16 +245,19 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Toolbar configuration
|
// Toolbar configuration
|
||||||
const toolbarConfig = {
|
const toolbarConfig = computed(() => ({
|
||||||
leftSection: [],
|
leftSection: [
|
||||||
rightSection: []
|
{ type: 'button', icon: ArrowLeftIcon, label: 'Retour', onClick: () => router.push({ name: 'scrapper-configurations' }) },
|
||||||
};
|
{ type: 'divider' },
|
||||||
|
{ type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
...(isEditing.value ? [{ type: 'button', icon: TrashIcon, label: 'Supprimer', onClick: () => { isDeleteModalOpen.value = true; }, class: 'text-red-600 hover:text-red-700' }, { type: 'divider' }] : []),
|
||||||
|
{ type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const goBack = () => {
|
|
||||||
router.push({ name: 'scrapper-configurations' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (formData) => {
|
const handleSubmit = async (formData) => {
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
@@ -279,6 +294,11 @@ const handleTest = async ({ configuration, testData }) => {
|
|||||||
testResults.value = {};
|
testResults.value = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Persister testSlug + testChapterNumber avant de lancer le test
|
||||||
|
if (isEditing.value) {
|
||||||
|
await contentSourceStore.updateSource(route.params.id, configuration);
|
||||||
|
}
|
||||||
|
|
||||||
// Préparer les données selon le format de l'API
|
// Préparer les données selon le format de l'API
|
||||||
const testConfiguration = {
|
const testConfiguration = {
|
||||||
baseUrl: configuration.baseUrl,
|
baseUrl: configuration.baseUrl,
|
||||||
@@ -323,6 +343,21 @@ const handleImageLoad = (event) => {
|
|||||||
event.target.style.display = 'block';
|
event.target.style.display = 'block';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeleteSource = async () => {
|
||||||
|
isDeleting.value = true;
|
||||||
|
deleteError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contentSourceStore.deleteSource(route.params.id);
|
||||||
|
isDeleteModalOpen.value = false;
|
||||||
|
await router.push({ name: 'scrapper-configurations' });
|
||||||
|
} catch (error) {
|
||||||
|
deleteError.value = error.message;
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatErrorType = (type) => {
|
const formatErrorType = (type) => {
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
'selector_error': 'Erreur sélecteur',
|
'selector_error': 'Erreur sélecteur',
|
||||||
|
|||||||
110
assets/vue/app/domain/system/application/store/logsStore.js
Normal file
110
assets/vue/app/domain/system/application/store/logsStore.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiJobRepository } from '../../../activity/infrastructure/api/ApiJobRepository';
|
||||||
|
|
||||||
|
const jobRepository = new ApiJobRepository();
|
||||||
|
|
||||||
|
// Statuts disponibles par filtre
|
||||||
|
const STATUS_MAP = {
|
||||||
|
failed: ['failed'],
|
||||||
|
completed: ['completed'],
|
||||||
|
all: ['failed', 'completed'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLogsStore = defineStore('logs', {
|
||||||
|
state: () => ({
|
||||||
|
logs: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
total: 0,
|
||||||
|
limit: 50,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortOrder: 'DESC',
|
||||||
|
statusFilter: 'failed', // 'failed' | 'completed' | 'all'
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isLoading: state => state.loading,
|
||||||
|
hasError: state => !!state.error,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadLogs(page = null) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collection = await jobRepository.getJobs({
|
||||||
|
page: page || this.currentPage,
|
||||||
|
limit: this.limit,
|
||||||
|
sortBy: this.sortBy,
|
||||||
|
sortOrder: this.sortOrder,
|
||||||
|
status: STATUS_MAP[this.statusFilter],
|
||||||
|
type: 'scraping_job',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logs = collection.items;
|
||||||
|
this.currentPage = collection.page;
|
||||||
|
this.total = collection.total;
|
||||||
|
this.hasNextPage = collection.hasNextPage;
|
||||||
|
this.hasPreviousPage = collection.hasPreviousPage;
|
||||||
|
this.totalPages = Math.ceil(this.total / this.limit);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async goToPage(page) {
|
||||||
|
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||||
|
this.currentPage = page;
|
||||||
|
await this.loadLogs(page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSort(sortBy, sortOrder) {
|
||||||
|
this.sortBy = sortBy;
|
||||||
|
this.sortOrder = sortOrder;
|
||||||
|
this.currentPage = 1;
|
||||||
|
await this.loadLogs(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setStatusFilter(filter) {
|
||||||
|
this.statusFilter = filter;
|
||||||
|
this.currentPage = 1;
|
||||||
|
await this.loadLogs(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteLog(id) {
|
||||||
|
try {
|
||||||
|
await jobRepository.deleteJob(id);
|
||||||
|
this.logs = this.logs.filter(log => log.id !== id);
|
||||||
|
this.total = Math.max(0, this.total - 1);
|
||||||
|
this.totalPages = Math.ceil(this.total / this.limit);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAllLogs() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobRepository.deleteJobs({
|
||||||
|
status: STATUS_MAP[this.statusFilter].join(','),
|
||||||
|
type: 'scraping_job',
|
||||||
|
});
|
||||||
|
await this.loadLogs(1);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
131
assets/vue/app/domain/system/presentation/components/LogItem.vue
Normal file
131
assets/vue/app/domain/system/presentation/components/LogItem.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 py-4 px-6">
|
||||||
|
<!-- Ligne 1 : Titre manga + chapitre + badge statut + date + bouton supprimer -->
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{{ log.context?.mangaTitle ?? 'Manga inconnu' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 text-sm shrink-0">•</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400 shrink-0">
|
||||||
|
Chapitre {{ log.context?.chapterNumber ?? '?' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-1.5 py-0.5 text-xs font-medium shrink-0',
|
||||||
|
log.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
]">
|
||||||
|
{{ log.status === 'completed' ? 'Terminé' : 'Échec' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ formattedDate }}</span>
|
||||||
|
<button
|
||||||
|
@click="$emit('delete', log.id)"
|
||||||
|
class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Supprimer ce log">
|
||||||
|
<TrashIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne 2 : Source + slug + durée -->
|
||||||
|
<div class="flex items-center justify-between mt-1 gap-4">
|
||||||
|
<div class="flex items-center gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<!-- Domaine de la source (lien vers la page d'édition) -->
|
||||||
|
<RouterLink
|
||||||
|
v-if="source"
|
||||||
|
:to="{ name: 'scrapper-edit', params: { id: source.id } }"
|
||||||
|
class="flex items-center gap-1 hover:text-blue-500 dark:hover:text-blue-400 transition-colors shrink-0">
|
||||||
|
<GlobeAltIcon class="w-3.5 h-3.5" />
|
||||||
|
<span class="font-mono">{{ cleanDomain }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else class="font-mono shrink-0">
|
||||||
|
ID {{ log.context?.sourceId ?? '-' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Badge type de scraping -->
|
||||||
|
<span
|
||||||
|
v-if="source?.scrapingType"
|
||||||
|
:class="[
|
||||||
|
'px-1.5 py-0.5 text-xs font-medium shrink-0',
|
||||||
|
source.scrapingType === 'Javascript'
|
||||||
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
]">
|
||||||
|
{{ source.scrapingType }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Slug utilisé -->
|
||||||
|
<span v-if="log.context?.slug" class="truncate text-gray-400 dark:text-gray-500">
|
||||||
|
slug : <span class="font-mono">{{ log.context.slug }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span v-if="duration !== null" class="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||||
|
{{ duration }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne 3 : Message d'erreur -->
|
||||||
|
<div v-if="log.error" class="mt-2">
|
||||||
|
<p
|
||||||
|
:class="[
|
||||||
|
'text-sm font-mono text-red-600 dark:text-red-400',
|
||||||
|
!expanded && isLong ? 'line-clamp-1' : ''
|
||||||
|
]">
|
||||||
|
↳ {{ log.error }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="isLong"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
class="mt-1 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
{{ expanded ? 'voir moins' : 'voir plus' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { GlobeAltIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
log: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['delete']);
|
||||||
|
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
const isLong = computed(() => props.log.error && props.log.error.length > 120);
|
||||||
|
|
||||||
|
const cleanDomain = computed(() => {
|
||||||
|
if (!props.source?.baseUrl) return null;
|
||||||
|
return props.source.baseUrl.replace(/^(https?:\/\/)?(www\.)?/, '').replace(/\/+$/, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.log.createdAt) return '';
|
||||||
|
const d = new Date(props.log.createdAt);
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = computed(() => {
|
||||||
|
if (!props.log.startedAt || !props.log.completedAt) return null;
|
||||||
|
const ms = new Date(props.log.completedAt) - new Date(props.log.startedAt);
|
||||||
|
if (ms < 0) return null;
|
||||||
|
return `${(ms / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })}s`;
|
||||||
|
});
|
||||||
|
</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>
|
||||||
165
assets/vue/app/domain/system/presentation/pages/LogsPage.vue
Normal file
165
assets/vue/app/domain/system/presentation/pages/LogsPage.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading" 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="hasError" class="px-6 py-8">
|
||||||
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
|
||||||
|
<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="logsStore.loadLogs()"
|
||||||
|
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="!isLoading && logs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||||
|
<ExclamationCircleIcon class="w-12 h-12 mb-3" />
|
||||||
|
<p class="text-base">Aucune erreur de scraping</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<template v-else>
|
||||||
|
<LogItem
|
||||||
|
v-for="log in logs"
|
||||||
|
:key="log.id"
|
||||||
|
:log="log"
|
||||||
|
:source="getSource(log)"
|
||||||
|
@delete="handleDelete" />
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-pages="totalPages"
|
||||||
|
:total="total"
|
||||||
|
:limit="limit"
|
||||||
|
:has-next-page="hasNextPage"
|
||||||
|
:has-previous-page="hasPreviousPage"
|
||||||
|
@page-change="logsStore.goToPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, ExclamationCircleIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { BarsArrowDownIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||||
|
import { useContentSourceStore } from '../../../setting/application/store/contentSourceStore';
|
||||||
|
import { useLogsStore } from '../../application/store/logsStore';
|
||||||
|
import LogItem from '../components/LogItem.vue';
|
||||||
|
|
||||||
|
const logsStore = useLogsStore();
|
||||||
|
const contentSourceStore = useContentSourceStore();
|
||||||
|
const { sources } = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
const {
|
||||||
|
logs,
|
||||||
|
loading: isLoading,
|
||||||
|
error,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
statusFilter,
|
||||||
|
} = storeToRefs(logsStore);
|
||||||
|
|
||||||
|
const hasError = computed(() => !!error.value);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
logsStore.loadLogs();
|
||||||
|
contentSourceStore.loadSources();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSource(log) {
|
||||||
|
const sourceId = log.context?.sourceId;
|
||||||
|
if (!sourceId) return null;
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
return sources.value.find(s => s.id == sourceId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||||
|
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ key: 'failed', label: 'Échecs' },
|
||||||
|
{ key: 'completed', label: 'Terminés' },
|
||||||
|
{ key: 'all', label: 'Tous' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Logs', class: 'text-sm font-medium' },
|
||||||
|
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
...STATUS_FILTERS.map(f => ({
|
||||||
|
type: 'button',
|
||||||
|
label: f.label,
|
||||||
|
active: statusFilter.value === f.key,
|
||||||
|
onClick: () => logsStore.setStatusFilter(f.key),
|
||||||
|
})),
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'dropdown',
|
||||||
|
icon: BarsArrowDownIcon,
|
||||||
|
label: 'Trier',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Plus récent',
|
||||||
|
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||||
|
onClick: () => logsStore.updateSort('createdAt', 'DESC'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Plus ancien',
|
||||||
|
isSelected: isSortSelected('createdAt', 'ASC'),
|
||||||
|
onClick: () => logsStore.updateSort('createdAt', 'ASC'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Rafraîchir',
|
||||||
|
disabled: isLoading.value,
|
||||||
|
onClick: () => logsStore.loadLogs(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: TrashIcon,
|
||||||
|
label: 'Tout supprimer',
|
||||||
|
disabled: isLoading.value || total.value === 0,
|
||||||
|
onClick: handleDeleteAll,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
await logsStore.deleteLog(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAll() {
|
||||||
|
if (!confirm('Supprimer tous les logs d\'erreur ? Cette action est irréversible.')) return;
|
||||||
|
await logsStore.deleteAllLogs();
|
||||||
|
}
|
||||||
|
</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>
|
||||||
@@ -10,24 +10,10 @@ import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
|||||||
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||||
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 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: '/',
|
||||||
@@ -65,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',
|
||||||
@@ -90,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',
|
||||||
@@ -129,34 +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: PlaceholderComponent,
|
component: LogsPage,
|
||||||
props: { title: 'Journaux système' }
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/system/updates',
|
|
||||||
name: 'system-updates',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Mises à jour' }
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
||||||
isReaderMode ? '' : 'md:ml-60'
|
isReaderMode ? '' : 'md:ml-60'
|
||||||
]" style="transition: margin-top 300ms ease-in-out;">
|
]" style="transition: margin-top 300ms ease-in-out;">
|
||||||
<RouterView></RouterView>
|
<RouterView class="flex-1 min-h-0"></RouterView>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ framework:
|
|||||||
command.bus:
|
command.bus:
|
||||||
middleware:
|
middleware:
|
||||||
- validation
|
- validation
|
||||||
- doctrine_transaction
|
|
||||||
event.bus:
|
event.bus:
|
||||||
default_middleware: allow_no_handlers
|
default_middleware: allow_no_handlers
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: messenger.message_handler, bus: command.bus }
|
- { name: messenger.message_handler, bus: command.bus }
|
||||||
|
|
||||||
|
# Scraper Health Check
|
||||||
|
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface:
|
||||||
|
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
|
||||||
|
|
||||||
|
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface:
|
||||||
|
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
|
||||||
|
|
||||||
# Import Domain Services
|
# Import Domain Services
|
||||||
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
|
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
|
||||||
|
|
||||||
@@ -193,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'
|
||||||
|
|||||||
41
migrations/Version20260315221706.php
Normal file
41
migrations/Version20260315221706.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260315221706 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE content_source ADD test_slug VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE content_source ADD test_chapter_number DOUBLE PRECISION DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE content_source ADD health_status VARCHAR(20) DEFAULT \'unknown\' NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE content_source ADD health_last_tested_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE content_source ADD health_last_error TEXT DEFAULT NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('ALTER TABLE content_source DROP test_slug');
|
||||||
|
$this->addSql('ALTER TABLE content_source DROP test_chapter_number');
|
||||||
|
$this->addSql('ALTER TABLE content_source DROP health_status');
|
||||||
|
$this->addSql('ALTER TABLE content_source DROP health_last_tested_at');
|
||||||
|
$this->addSql('ALTER TABLE content_source DROP health_last_error');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Application\Command;
|
||||||
|
|
||||||
|
readonly class CheckAllScrapersHealth
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ namespace App\Domain\Scraping\Application\Command;
|
|||||||
readonly class ScrapeChapter
|
readonly class ScrapeChapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $chapterId
|
public string $chapterId,
|
||||||
|
public string $jobId
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Application\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Application\Command\CheckAllScrapersHealth;
|
||||||
|
use App\Domain\Scraping\Application\Command\TestScraperConfiguration;
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface;
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
readonly class CheckAllScrapersHealthHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ContentSourceForHealthCheckInterface $contentSourceForHealthCheckRepo,
|
||||||
|
private ContentSourceHealthRepositoryInterface $contentSourceHealthRepo,
|
||||||
|
private TestScraperConfigurationHandler $testScraperConfigurationHandler,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(CheckAllScrapersHealth $command): void
|
||||||
|
{
|
||||||
|
$sources = $this->contentSourceForHealthCheckRepo->getAll();
|
||||||
|
|
||||||
|
foreach ($sources as $source) {
|
||||||
|
if ($source->testSlug === null || $source->testChapterNumber === null) {
|
||||||
|
$this->logger->warning('ContentSource {id} has no test config, skipping health check.', ['id' => $source->id]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->contentSourceHealthRepo->markAsTesting($source->id);
|
||||||
|
$testUrl = str_replace(
|
||||||
|
['{slug}', '{chapterNumber}'],
|
||||||
|
[$source->testSlug, $source->testChapterNumber],
|
||||||
|
$source->chapterUrlFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
$testCommand = new TestScraperConfiguration(
|
||||||
|
baseUrl: $source->baseUrl,
|
||||||
|
chapterUrlFormat: $source->chapterUrlFormat,
|
||||||
|
scrapingType: $source->scrapingType,
|
||||||
|
testUrl: $testUrl,
|
||||||
|
mangaSlug: $source->testSlug,
|
||||||
|
chapterNumber: $source->testChapterNumber,
|
||||||
|
imageSelector: $source->imageSelector,
|
||||||
|
nextPageSelector: $source->nextPageSelector,
|
||||||
|
chapterSelector: $source->chapterSelector,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->testScraperConfigurationHandler->handle($testCommand);
|
||||||
|
|
||||||
|
if ($response->success) {
|
||||||
|
$this->contentSourceHealthRepo->markAsHealthy($source->id, new \DateTimeImmutable());
|
||||||
|
} else {
|
||||||
|
$firstError = $response->errors[0]['message'] ?? 'Erreur inconnue';
|
||||||
|
$this->contentSourceHealthRepo->markAsUnhealthy($source->id, new \DateTimeImmutable(), $firstError);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->contentSourceHealthRepo->markAsUnhealthy($source->id, new \DateTimeImmutable(), $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,11 @@ use App\Domain\Shared\Domain\Event\ChapterScraped;
|
|||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
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\Source;
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
|
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
|
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
|
||||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||||
use Ramsey\Uuid\Uuid;
|
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
|
|
||||||
readonly class ScrapeChapterHandler
|
readonly class ScrapeChapterHandler
|
||||||
{
|
{
|
||||||
@@ -33,64 +30,35 @@ readonly class ScrapeChapterHandler
|
|||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private SourceRepositoryInterface $sourceRepository,
|
private SourceRepositoryInterface $sourceRepository,
|
||||||
private MessageBusInterface $eventBus,
|
private MessageBusInterface $eventBus,
|
||||||
private EntityManagerInterface $entityManager
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ScrapeChapter $command): void
|
public function handle(ScrapeChapter $command): void
|
||||||
{
|
{
|
||||||
$job = null;
|
|
||||||
try {
|
|
||||||
// 1. Récupération du chapitre
|
|
||||||
/** @var Chapter $chapter */
|
/** @var Chapter $chapter */
|
||||||
$chapter = $this->chapterRepository->getById($command->chapterId);
|
$chapter = $this->chapterRepository->getById($command->chapterId);
|
||||||
if (!$chapter) {
|
|
||||||
throw new \InvalidArgumentException("Chapter not found with ID: {$command->chapterId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Récupération du manga
|
|
||||||
$manga = $this->mangaRepository->getById($chapter->mangaId);
|
$manga = $this->mangaRepository->getById($chapter->mangaId);
|
||||||
if (!$manga) {
|
|
||||||
throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Dispatch de l'événement de démarrage
|
$job = $this->jobRepository->get($command->jobId);
|
||||||
$this->eventBus->dispatch(new ChapterScrapingStarted($manga->getTitle(), $chapter->chapterNumber));
|
$job->context['chapterId'] = $command->chapterId;
|
||||||
|
$job->context['mangaTitle'] = $manga->getTitle();
|
||||||
|
$job->start();
|
||||||
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
|
$this->eventBus->dispatch(new ChapterScrapingStarted($job->id, $manga->getTitle(), $chapter->chapterNumber));
|
||||||
|
|
||||||
// 4. Détermination des sources à utiliser
|
|
||||||
$sources = $this->getSourcesToTry($manga);
|
$sources = $this->getSourcesToTry($manga);
|
||||||
if (empty($sources)) {
|
$slugsToTry = array_merge([$manga->getSlug()], $manga->getAlternativeSlugs());
|
||||||
throw new \InvalidArgumentException("No sources available for scraping");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Essai de scraping sur chaque source jusqu'à succès
|
|
||||||
$success = false;
|
$success = false;
|
||||||
$lastException = null;
|
$lastException = null;
|
||||||
|
|
||||||
foreach ($sources as $source) {
|
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) {
|
foreach ($slugsToTry as $slug) {
|
||||||
$job = new ScrapingJob(
|
try {
|
||||||
Uuid::uuid4()->toString(),
|
$job->context['sourceId'] = $source->getId()->getValue();
|
||||||
$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;
|
|
||||||
$job->context['slug'] = $slug;
|
$job->context['slug'] = $slug;
|
||||||
$job->context['mangaTitle'] = $manga->getTitle();
|
|
||||||
|
|
||||||
$job->start();
|
|
||||||
$this->jobRepository->save($job);
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
try {
|
|
||||||
$this->entityManager->beginTransaction();
|
|
||||||
|
|
||||||
// 5. Scraping des URLs avec le slug courant
|
|
||||||
$scrapingParameters = $source->getScrappingParameters();
|
$scrapingParameters = $source->getScrappingParameters();
|
||||||
$scrapingParameters['chapterNumber'] = $chapter->chapterNumber;
|
$scrapingParameters['chapterNumber'] = $chapter->chapterNumber;
|
||||||
$scrapingType = $scrapingParameters['scrapingType'] ?? 'html';
|
$scrapingType = $scrapingParameters['scrapingType'] ?? 'html';
|
||||||
@@ -101,11 +69,9 @@ readonly class ScrapeChapterHandler
|
|||||||
$scrapingParameters
|
$scrapingParameters
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sélection du scraper approprié selon le type
|
|
||||||
$scraper = $this->scraperFactory->getScraperWithFallback($scrapingType);
|
$scraper = $this->scraperFactory->getScraperWithFallback($scrapingType);
|
||||||
$scrapingResult = $scraper->scrape($scrapingRequest);
|
$scrapingResult = $scraper->scrape($scrapingRequest);
|
||||||
|
|
||||||
// 6. Téléchargement des images
|
|
||||||
$tempDir = new TempDirectory();
|
$tempDir = new TempDirectory();
|
||||||
$downloadResults = $this->imageDownloader->downloadBatch(
|
$downloadResults = $this->imageDownloader->downloadBatch(
|
||||||
$scrapingResult->getImageUrls(),
|
$scrapingResult->getImageUrls(),
|
||||||
@@ -113,7 +79,6 @@ readonly class ScrapeChapterHandler
|
|||||||
$job->id
|
$job->id
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. Stockage des images individuelles
|
|
||||||
$localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults);
|
$localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults);
|
||||||
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths);
|
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths);
|
||||||
$pageCount = count($downloadResults);
|
$pageCount = count($downloadResults);
|
||||||
@@ -121,63 +86,36 @@ readonly class ScrapeChapterHandler
|
|||||||
$job->complete();
|
$job->complete();
|
||||||
$this->jobRepository->save($job);
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
$this->entityManager->commit();
|
|
||||||
|
|
||||||
$this->eventBus->dispatch(new ChapterScraped($job->id, $command->chapterId, $pagesDirectory, $pageCount));
|
$this->eventBus->dispatch(new ChapterScraped($job->id, $command->chapterId, $pagesDirectory, $pageCount));
|
||||||
|
|
||||||
// 8. Nettoyage
|
|
||||||
$tempDir->cleanup();
|
$tempDir->cleanup();
|
||||||
|
|
||||||
// Scraping réussi, pas besoin d'essayer d'autres slugs ni d'autres sources
|
|
||||||
$success = true;
|
$success = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} 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;
|
$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) {
|
if ($success) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si toutes les sources ont échoué
|
|
||||||
if (!$success) {
|
if (!$success) {
|
||||||
$errorMessage = $lastException ? $lastException->getMessage() : "Failed to scrape chapter from all available sources";
|
$errorMessage = $lastException?->getMessage() ?? 'Failed to scrape chapter from all available sources';
|
||||||
$this->eventBus->dispatch(new ChapterScrapingFailed($chapter->mangaId, $chapter->chapterNumber, $errorMessage));
|
$job->fail($errorMessage);
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
if (isset($job)) {
|
|
||||||
$job->fail($e->getMessage());
|
|
||||||
$this->jobRepository->save($job);
|
$this->jobRepository->save($job);
|
||||||
}
|
$this->eventBus->dispatch(new ChapterScrapingFailed($job->id, $chapter->mangaId, $chapter->chapterNumber, $errorMessage));
|
||||||
$this->eventBus->dispatch(new ChapterScrapingFailed($chapter->mangaId ?? 'unknown', $chapter->chapterNumber ?? 'unknown', $e->getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détermine les sources à utiliser pour le scraping en fonction des préférences du manga
|
|
||||||
*
|
|
||||||
* @param \App\Domain\Scraping\Domain\Model\Manga $manga
|
* @param \App\Domain\Scraping\Domain\Model\Manga $manga
|
||||||
* @return Source[]
|
* @return Source[]
|
||||||
*/
|
*/
|
||||||
private function getSourcesToTry(\App\Domain\Scraping\Domain\Model\Manga $manga): array
|
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()) {
|
if ($manga->hasPreferredSources()) {
|
||||||
$preferredSources = [];
|
$preferredSources = [];
|
||||||
foreach ($manga->getPreferredSources() as $sourceId) {
|
foreach ($manga->getPreferredSources() as $sourceId) {
|
||||||
@@ -186,7 +124,6 @@ readonly class ScrapeChapterHandler
|
|||||||
$preferredSources[] = $source;
|
$preferredSources[] = $source;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limiter à 3 sources préférées maximum
|
|
||||||
if (count($preferredSources) >= 3) {
|
if (count($preferredSources) >= 3) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -197,7 +134,6 @@ readonly class ScrapeChapterHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, utiliser toutes les sources disponibles
|
|
||||||
return $this->sourceRepository->getAll();
|
return $this->sourceRepository->getAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Domain\Contract\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
|
||||||
|
|
||||||
|
interface ContentSourceForHealthCheckInterface
|
||||||
|
{
|
||||||
|
/** @return ContentSourceHealthCheckData[] */
|
||||||
|
public function getAll(): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Domain\Contract\Repository;
|
||||||
|
|
||||||
|
interface ContentSourceHealthRepositoryInterface
|
||||||
|
{
|
||||||
|
public function markAsTesting(int $sourceId): void;
|
||||||
|
|
||||||
|
public function markAsHealthy(int $sourceId, \DateTimeImmutable $testedAt): void;
|
||||||
|
|
||||||
|
public function markAsUnhealthy(int $sourceId, \DateTimeImmutable $testedAt, string $error): void;
|
||||||
|
}
|
||||||
@@ -5,12 +5,18 @@ namespace App\Domain\Scraping\Domain\Event;
|
|||||||
readonly class ChapterScrapingFailed
|
readonly class ChapterScrapingFailed
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private string $jobId,
|
||||||
private string $mangaId,
|
private string $mangaId,
|
||||||
private string $chapterNumber,
|
private string $chapterNumber,
|
||||||
private string $reason
|
private string $reason
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getJobId(): string
|
||||||
|
{
|
||||||
|
return $this->jobId;
|
||||||
|
}
|
||||||
|
|
||||||
public function getMangaId(): string
|
public function getMangaId(): string
|
||||||
{
|
{
|
||||||
return $this->mangaId;
|
return $this->mangaId;
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ namespace App\Domain\Scraping\Domain\Event;
|
|||||||
class ChapterScrapingStarted
|
class ChapterScrapingStarted
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly string $jobId,
|
||||||
private readonly string $mangaTitle,
|
private readonly string $mangaTitle,
|
||||||
private readonly float $chapterNumber,
|
private readonly float $chapterNumber,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getJobId(): string
|
||||||
|
{
|
||||||
|
return $this->jobId;
|
||||||
|
}
|
||||||
|
|
||||||
public function getMangaTitle(): string
|
public function getMangaTitle(): string
|
||||||
{
|
{
|
||||||
return $this->mangaTitle;
|
return $this->mangaTitle;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ class ScrapingJob extends Job
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $id,
|
string $id,
|
||||||
string $mangaId,
|
?string $mangaId = null,
|
||||||
float $chapterNumber,
|
?float $chapterNumber = null,
|
||||||
string $sourceId
|
?string $sourceId = null
|
||||||
) {
|
) {
|
||||||
parent::__construct($id, 'scraping_job');
|
parent::__construct($id, 'scraping_job');
|
||||||
$this->maxAttempts = 1;
|
$this->maxAttempts = 1;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Domain\Model\ValueObject;
|
||||||
|
|
||||||
|
readonly class ContentSourceHealthCheckData
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public string $baseUrl,
|
||||||
|
public string $chapterUrlFormat,
|
||||||
|
public string $scrapingType,
|
||||||
|
public ?string $imageSelector,
|
||||||
|
public ?string $nextPageSelector,
|
||||||
|
public ?string $chapterSelector,
|
||||||
|
public ?string $testSlug,
|
||||||
|
public ?float $testChapterNumber,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor\CheckAllScrapersHealthStateProcessor;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'Scraping',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/scraping/check-all-health',
|
||||||
|
processor: CheckAllScrapersHealthStateProcessor::class,
|
||||||
|
output: false,
|
||||||
|
status: 202,
|
||||||
|
description: 'Déclenche le test de santé de tous les scrapers configurés avec testSlug',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class CheckAllScrapersHealthResource
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Domain\Scraping\Application\Command\CheckAllScrapersHealth;
|
||||||
|
use App\Domain\Scraping\Application\CommandHandler\CheckAllScrapersHealthHandler;
|
||||||
|
|
||||||
|
readonly class CheckAllScrapersHealthStateProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CheckAllScrapersHealthHandler $handler,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||||
|
{
|
||||||
|
$this->handler->handle(new CheckAllScrapersHealth());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,20 @@ namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
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\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;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
final class ScrapeChapterStateProcessor implements ProcessorInterface
|
final class ScrapeChapterStateProcessor implements ProcessorInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
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
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||||
{
|
{
|
||||||
$this->commandBus->dispatch(
|
$jobId = Uuid::uuid4()->toString();
|
||||||
new ScrapeChapter(
|
$job = new ScrapingJob($jobId);
|
||||||
$data->chapterId
|
$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\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
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\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||||
@@ -30,14 +31,32 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
|||||||
return [];
|
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]
|
#[AsMessageHandler]
|
||||||
public function onChapterScrapingStarted(ChapterScrapingStarted $event): void
|
public function onChapterScrapingStarted(ChapterScrapingStarted $event): void
|
||||||
{
|
{
|
||||||
$chapterNumber = $event->getChapterNumber();
|
$this->hub->publish(new Update(
|
||||||
$mangaTitle = $event->getMangaTitle();
|
'jobs/activity',
|
||||||
|
json_encode(['type' => 'job.status_changed', 'jobId' => $event->getJobId(), 'status' => 'in_progress'])
|
||||||
|
));
|
||||||
|
|
||||||
$this->notification->sendInfo(
|
$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));
|
$update = new Update($topics, json_encode($data));
|
||||||
$this->hub->publish($update);
|
$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';
|
$mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu';
|
||||||
$this->notification->sendSuccess(
|
$this->notification->sendSuccess(
|
||||||
sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle)
|
sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle)
|
||||||
@@ -93,6 +117,11 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
|||||||
#[AsMessageHandler]
|
#[AsMessageHandler]
|
||||||
public function onChapterScrapingFailed(ChapterScrapingFailed $event): void
|
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());
|
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ readonly class ImageDownloader implements ImageDownloaderInterface
|
|||||||
|
|
||||||
public function download(string $url, string $destination): void
|
public function download(string $url, string $destination): void
|
||||||
{
|
{
|
||||||
$response = $this->httpClient->request('GET', $url);
|
$urlParts = parse_url($url);
|
||||||
|
$referer = ($urlParts['scheme'] ?? 'https') . '://' . ($urlParts['host'] ?? '');
|
||||||
|
|
||||||
|
$response = $this->httpClient->request('GET', $url, [
|
||||||
|
'headers' => [
|
||||||
|
'Referer' => $referer,
|
||||||
|
],
|
||||||
|
]);
|
||||||
$contentType = $response->getHeaders()['content-type'][0] ?? '';
|
$contentType = $response->getHeaders()['content-type'][0] ?? '';
|
||||||
|
|
||||||
if (!str_starts_with($contentType, 'image/')) {
|
if (!str_starts_with($contentType, 'image/')) {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Setting\Application\Command;
|
||||||
|
|
||||||
|
readonly class DeleteContentSourceCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ readonly class UpsertContentSourceCommand
|
|||||||
public ?string $imageSelector = null,
|
public ?string $imageSelector = null,
|
||||||
public ?string $nextPageSelector = null,
|
public ?string $nextPageSelector = null,
|
||||||
public ?string $chapterSelector = null,
|
public ?string $chapterSelector = null,
|
||||||
|
public ?string $testSlug = null,
|
||||||
|
public ?float $testChapterNumber = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Setting\Application\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Setting\Application\Command\DeleteContentSourceCommand;
|
||||||
|
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
|
||||||
|
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
|
||||||
|
|
||||||
|
readonly class DeleteContentSourceCommandHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ContentSourceRepositoryInterface $contentSourceRepository
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(DeleteContentSourceCommand $command): void
|
||||||
|
{
|
||||||
|
$contentSource = $this->contentSourceRepository->findById($command->id);
|
||||||
|
|
||||||
|
if (!$contentSource) {
|
||||||
|
throw new ContentSourceNotFoundException($command->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->contentSourceRepository->delete($contentSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ readonly class UpsertContentSourceCommandHandler
|
|||||||
imageSelector: $command->imageSelector,
|
imageSelector: $command->imageSelector,
|
||||||
nextPageSelector: $command->nextPageSelector,
|
nextPageSelector: $command->nextPageSelector,
|
||||||
chapterSelector: $command->chapterSelector,
|
chapterSelector: $command->chapterSelector,
|
||||||
|
testSlug: $command->testSlug,
|
||||||
|
testChapterNumber: $command->testChapterNumber,
|
||||||
);
|
);
|
||||||
$this->contentSourceRepository->save($contentSource);
|
$this->contentSourceRepository->save($contentSource);
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,8 @@ readonly class UpsertContentSourceCommandHandler
|
|||||||
imageSelector: $command->imageSelector,
|
imageSelector: $command->imageSelector,
|
||||||
nextPageSelector: $command->nextPageSelector,
|
nextPageSelector: $command->nextPageSelector,
|
||||||
chapterSelector: $command->chapterSelector,
|
chapterSelector: $command->chapterSelector,
|
||||||
|
testSlug: $command->testSlug,
|
||||||
|
testChapterNumber: $command->testChapterNumber,
|
||||||
);
|
);
|
||||||
$this->contentSourceRepository->save($contentSource);
|
$this->contentSourceRepository->save($contentSource);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ readonly class ContentSourceResponse
|
|||||||
public ?string $nextPageSelector,
|
public ?string $nextPageSelector,
|
||||||
public ?string $chapterSelector,
|
public ?string $chapterSelector,
|
||||||
public string $cleanBaseUrl,
|
public string $cleanBaseUrl,
|
||||||
|
public ?string $testSlug = null,
|
||||||
|
public ?float $testChapterNumber = null,
|
||||||
|
public string $healthStatus = 'unknown',
|
||||||
|
public ?\DateTimeImmutable $healthLastTestedAt = null,
|
||||||
|
public ?string $healthLastError = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +34,11 @@ readonly class ContentSourceResponse
|
|||||||
nextPageSelector: $contentSource->getNextPageSelector(),
|
nextPageSelector: $contentSource->getNextPageSelector(),
|
||||||
chapterSelector: $contentSource->getChapterSelector(),
|
chapterSelector: $contentSource->getChapterSelector(),
|
||||||
cleanBaseUrl: $contentSource->getCleanBaseUrl(),
|
cleanBaseUrl: $contentSource->getCleanBaseUrl(),
|
||||||
|
testSlug: $contentSource->getTestSlug(),
|
||||||
|
testChapterNumber: $contentSource->getTestChapterNumber(),
|
||||||
|
healthStatus: $contentSource->getHealthStatus(),
|
||||||
|
healthLastTestedAt: $contentSource->getHealthLastTestedAt(),
|
||||||
|
healthLastError: $contentSource->getHealthLastError(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ final class ContentSource
|
|||||||
private ?string $imageSelector = null,
|
private ?string $imageSelector = null,
|
||||||
private ?string $nextPageSelector = null,
|
private ?string $nextPageSelector = null,
|
||||||
private ?string $chapterSelector = null,
|
private ?string $chapterSelector = null,
|
||||||
|
private ?string $testSlug = null,
|
||||||
|
private ?float $testChapterNumber = null,
|
||||||
|
private string $healthStatus = 'unknown',
|
||||||
|
private ?\DateTimeImmutable $healthLastTestedAt = null,
|
||||||
|
private ?string $healthLastError = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +55,44 @@ final class ContentSource
|
|||||||
return $this->chapterSelector;
|
return $this->chapterSelector;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTestSlug(): ?string
|
||||||
|
{
|
||||||
|
return $this->testSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTestChapterNumber(): ?float
|
||||||
|
{
|
||||||
|
return $this->testChapterNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHealthStatus(): string
|
||||||
|
{
|
||||||
|
return $this->healthStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHealthLastTestedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->healthLastTestedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHealthLastError(): ?string
|
||||||
|
{
|
||||||
|
return $this->healthLastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTestConfig(?string $testSlug, ?float $testChapterNumber): void
|
||||||
|
{
|
||||||
|
$this->testSlug = $testSlug;
|
||||||
|
$this->testChapterNumber = $testChapterNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateHealthStatus(string $status, ?\DateTimeImmutable $testedAt = null, ?string $error = null): void
|
||||||
|
{
|
||||||
|
$this->healthStatus = $status;
|
||||||
|
$this->healthLastTestedAt = $testedAt;
|
||||||
|
$this->healthLastError = $error;
|
||||||
|
}
|
||||||
|
|
||||||
public function updateId(int $id): void
|
public function updateId(int $id): void
|
||||||
{
|
{
|
||||||
$this->id = $id;
|
$this->id = $id;
|
||||||
@@ -71,6 +114,8 @@ final class ContentSource
|
|||||||
?string $imageSelector = null,
|
?string $imageSelector = null,
|
||||||
?string $nextPageSelector = null,
|
?string $nextPageSelector = null,
|
||||||
?string $chapterSelector = null,
|
?string $chapterSelector = null,
|
||||||
|
?string $testSlug = null,
|
||||||
|
?float $testChapterNumber = null,
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
id: null,
|
id: null,
|
||||||
@@ -80,6 +125,8 @@ final class ContentSource
|
|||||||
imageSelector: $imageSelector,
|
imageSelector: $imageSelector,
|
||||||
nextPageSelector: $nextPageSelector,
|
nextPageSelector: $nextPageSelector,
|
||||||
chapterSelector: $chapterSelector,
|
chapterSelector: $chapterSelector,
|
||||||
|
testSlug: $testSlug,
|
||||||
|
testChapterNumber: $testChapterNumber,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +137,8 @@ final class ContentSource
|
|||||||
?string $imageSelector = null,
|
?string $imageSelector = null,
|
||||||
?string $nextPageSelector = null,
|
?string $nextPageSelector = null,
|
||||||
?string $chapterSelector = null,
|
?string $chapterSelector = null,
|
||||||
|
?string $testSlug = null,
|
||||||
|
?float $testChapterNumber = null,
|
||||||
): void {
|
): void {
|
||||||
$this->baseUrl = $baseUrl;
|
$this->baseUrl = $baseUrl;
|
||||||
$this->chapterUrlFormat = $chapterUrlFormat;
|
$this->chapterUrlFormat = $chapterUrlFormat;
|
||||||
@@ -97,5 +146,7 @@ final class ContentSource
|
|||||||
$this->imageSelector = $imageSelector;
|
$this->imageSelector = $imageSelector;
|
||||||
$this->nextPageSelector = $nextPageSelector;
|
$this->nextPageSelector = $nextPageSelector;
|
||||||
$this->chapterSelector = $chapterSelector;
|
$this->chapterSelector = $chapterSelector;
|
||||||
|
$this->testSlug = $testSlug;
|
||||||
|
$this->testChapterNumber = $testChapterNumber;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Setting\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\DeleteContentSourceStateProcessor;
|
||||||
|
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\DeleteContentSourceStateProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'ContentSource',
|
||||||
|
operations: [
|
||||||
|
new Delete(
|
||||||
|
uriTemplate: '/content-sources/{id}',
|
||||||
|
provider: DeleteContentSourceStateProvider::class,
|
||||||
|
processor: DeleteContentSourceStateProcessor::class,
|
||||||
|
name: 'delete_content_source',
|
||||||
|
openapiContext: [
|
||||||
|
'summary' => 'Delete a content source',
|
||||||
|
'description' => 'Permanently deletes a content source',
|
||||||
|
'parameters' => [
|
||||||
|
[
|
||||||
|
'name' => 'id',
|
||||||
|
'in' => 'path',
|
||||||
|
'required' => true,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'integer'
|
||||||
|
],
|
||||||
|
'description' => 'The content source ID'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'responses' => [
|
||||||
|
'204' => [
|
||||||
|
'description' => 'Content source successfully deleted'
|
||||||
|
],
|
||||||
|
'404' => [
|
||||||
|
'description' => 'Content source not found'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class DeleteContentSourceResource
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ class GetContentSourceResource
|
|||||||
public readonly ?string $nextPageSelector,
|
public readonly ?string $nextPageSelector,
|
||||||
public readonly ?string $chapterSelector,
|
public readonly ?string $chapterSelector,
|
||||||
public readonly string $cleanBaseUrl,
|
public readonly string $cleanBaseUrl,
|
||||||
|
public readonly ?string $testSlug = null,
|
||||||
|
public readonly ?float $testChapterNumber = null,
|
||||||
|
public readonly string $healthStatus = 'unknown',
|
||||||
|
public readonly ?\DateTimeImmutable $healthLastTestedAt = null,
|
||||||
|
public readonly ?string $healthLastError = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class UpsertContentSourceResource
|
|||||||
public readonly ?string $imageSelector = null,
|
public readonly ?string $imageSelector = null,
|
||||||
public readonly ?string $nextPageSelector = null,
|
public readonly ?string $nextPageSelector = null,
|
||||||
public readonly ?string $chapterSelector = null,
|
public readonly ?string $chapterSelector = null,
|
||||||
|
public readonly ?string $testSlug = null,
|
||||||
|
public readonly ?float $testChapterNumber = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Domain\Setting\Application\Command\DeleteContentSourceCommand;
|
||||||
|
use App\Domain\Setting\Application\CommandHandler\DeleteContentSourceCommandHandler;
|
||||||
|
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
readonly class DeleteContentSourceStateProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DeleteContentSourceCommandHandler $handler
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
|
||||||
|
{
|
||||||
|
if (!isset($uriVariables['id'])) {
|
||||||
|
throw new \InvalidArgumentException('Content source ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new DeleteContentSourceCommand((int) $uriVariables['id']);
|
||||||
|
$this->handler->handle($command);
|
||||||
|
|
||||||
|
return Response::HTTP_NO_CONTENT;
|
||||||
|
} catch (ContentSourceNotFoundException $e) {
|
||||||
|
throw new NotFoundHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ readonly class UpsertContentSourceStateProcessor implements ProcessorInterface
|
|||||||
imageSelector: $data->imageSelector,
|
imageSelector: $data->imageSelector,
|
||||||
nextPageSelector: $data->nextPageSelector,
|
nextPageSelector: $data->nextPageSelector,
|
||||||
chapterSelector: $data->chapterSelector,
|
chapterSelector: $data->chapterSelector,
|
||||||
|
testSlug: $data->testSlug,
|
||||||
|
testChapterNumber: $data->testChapterNumber,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->handler->handle($command);
|
$this->handler->handle($command);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
|
||||||
|
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
|
||||||
|
use App\Domain\Setting\Infrastructure\ApiPlatform\Resource\DeleteContentSourceResource;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
readonly class DeleteContentSourceStateProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ContentSourceRepositoryInterface $contentSourceRepository
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): DeleteContentSourceResource
|
||||||
|
{
|
||||||
|
if (!isset($uriVariables['id'])) {
|
||||||
|
throw new NotFoundHttpException('Content source ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $uriVariables['id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$contentSource = $this->contentSourceRepository->findById($id);
|
||||||
|
|
||||||
|
if (!$contentSource) {
|
||||||
|
throw new ContentSourceNotFoundException($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeleteContentSourceResource($id);
|
||||||
|
} catch (ContentSourceNotFoundException $e) {
|
||||||
|
throw new NotFoundHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ readonly class GetContentSourceStateProvider implements ProviderInterface
|
|||||||
nextPageSelector: $response->nextPageSelector,
|
nextPageSelector: $response->nextPageSelector,
|
||||||
chapterSelector: $response->chapterSelector,
|
chapterSelector: $response->chapterSelector,
|
||||||
cleanBaseUrl: $response->cleanBaseUrl,
|
cleanBaseUrl: $response->cleanBaseUrl,
|
||||||
|
testSlug: $response->testSlug,
|
||||||
|
testChapterNumber: $response->testChapterNumber,
|
||||||
|
healthStatus: $response->healthStatus,
|
||||||
|
healthLastTestedAt: $response->healthLastTestedAt,
|
||||||
|
healthLastError: $response->healthLastError,
|
||||||
);
|
);
|
||||||
} catch (ContentSourceNotFoundException $e) {
|
} catch (ContentSourceNotFoundException $e) {
|
||||||
throw new NotFoundHttpException($e->getMessage());
|
throw new NotFoundHttpException($e->getMessage());
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ readonly class ListContentSourceStateProvider implements ProviderInterface
|
|||||||
nextPageSelector: $contentSourceResponse->nextPageSelector,
|
nextPageSelector: $contentSourceResponse->nextPageSelector,
|
||||||
chapterSelector: $contentSourceResponse->chapterSelector,
|
chapterSelector: $contentSourceResponse->chapterSelector,
|
||||||
cleanBaseUrl: $contentSourceResponse->cleanBaseUrl,
|
cleanBaseUrl: $contentSourceResponse->cleanBaseUrl,
|
||||||
|
testSlug: $contentSourceResponse->testSlug,
|
||||||
|
testChapterNumber: $contentSourceResponse->testChapterNumber,
|
||||||
|
healthStatus: $contentSourceResponse->healthStatus,
|
||||||
|
healthLastTestedAt: $contentSourceResponse->healthLastTestedAt,
|
||||||
|
healthLastError: $contentSourceResponse->healthLastError,
|
||||||
),
|
),
|
||||||
$response->contentSources
|
$response->contentSources
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ readonly class ContentSourceMapper
|
|||||||
imageSelector: $entity->getImageSelector(),
|
imageSelector: $entity->getImageSelector(),
|
||||||
nextPageSelector: $entity->getNextPageSelector(),
|
nextPageSelector: $entity->getNextPageSelector(),
|
||||||
chapterSelector: $entity->getChapterSelector(),
|
chapterSelector: $entity->getChapterSelector(),
|
||||||
|
testSlug: $entity->getTestSlug(),
|
||||||
|
testChapterNumber: $entity->getTestChapterNumber(),
|
||||||
|
healthStatus: $entity->getHealthStatus(),
|
||||||
|
healthLastTestedAt: $entity->getHealthLastTestedAt(),
|
||||||
|
healthLastError: $entity->getHealthLastError(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +34,12 @@ readonly class ContentSourceMapper
|
|||||||
->setScrapingType($contentSource->getScrapingType())
|
->setScrapingType($contentSource->getScrapingType())
|
||||||
->setImageSelector($contentSource->getImageSelector())
|
->setImageSelector($contentSource->getImageSelector())
|
||||||
->setNextPageSelector($contentSource->getNextPageSelector())
|
->setNextPageSelector($contentSource->getNextPageSelector())
|
||||||
->setChapterSelector($contentSource->getChapterSelector());
|
->setChapterSelector($contentSource->getChapterSelector())
|
||||||
|
->setTestSlug($contentSource->getTestSlug())
|
||||||
|
->setTestChapterNumber($contentSource->getTestChapterNumber())
|
||||||
|
->setHealthStatus($contentSource->getHealthStatus())
|
||||||
|
->setHealthLastTestedAt($contentSource->getHealthLastTestedAt())
|
||||||
|
->setHealthLastError($contentSource->getHealthLastError());
|
||||||
|
|
||||||
return $entity;
|
return $entity;
|
||||||
}
|
}
|
||||||
@@ -41,7 +51,12 @@ readonly class ContentSourceMapper
|
|||||||
->setScrapingType($contentSource->getScrapingType())
|
->setScrapingType($contentSource->getScrapingType())
|
||||||
->setImageSelector($contentSource->getImageSelector())
|
->setImageSelector($contentSource->getImageSelector())
|
||||||
->setNextPageSelector($contentSource->getNextPageSelector())
|
->setNextPageSelector($contentSource->getNextPageSelector())
|
||||||
->setChapterSelector($contentSource->getChapterSelector());
|
->setChapterSelector($contentSource->getChapterSelector())
|
||||||
|
->setTestSlug($contentSource->getTestSlug())
|
||||||
|
->setTestChapterNumber($contentSource->getTestChapterNumber())
|
||||||
|
->setHealthStatus($contentSource->getHealthStatus())
|
||||||
|
->setHealthLastTestedAt($contentSource->getHealthLastTestedAt())
|
||||||
|
->setHealthLastError($contentSource->getHealthLastError());
|
||||||
|
|
||||||
return $entity;
|
return $entity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Setting\Infrastructure\Persistence\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface;
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface;
|
||||||
|
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
|
||||||
|
use App\Entity\ContentSource as ContentSourceEntity;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
|
use Symfony\Component\Mercure\Update;
|
||||||
|
|
||||||
|
readonly class DoctrineContentSourceForHealthCheckRepository implements ContentSourceForHealthCheckInterface, ContentSourceHealthRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private HubInterface $hub,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(): array
|
||||||
|
{
|
||||||
|
$entities = $this->entityManager->getRepository(ContentSourceEntity::class)->findAll();
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (ContentSourceEntity $entity) => new ContentSourceHealthCheckData(
|
||||||
|
id: $entity->getId(),
|
||||||
|
baseUrl: $entity->getBaseUrl(),
|
||||||
|
chapterUrlFormat: $entity->getChapterUrlFormat(),
|
||||||
|
scrapingType: $entity->getScrapingType(),
|
||||||
|
imageSelector: $entity->getImageSelector(),
|
||||||
|
nextPageSelector: $entity->getNextPageSelector(),
|
||||||
|
chapterSelector: $entity->getChapterSelector(),
|
||||||
|
testSlug: $entity->getTestSlug(),
|
||||||
|
testChapterNumber: $entity->getTestChapterNumber(),
|
||||||
|
),
|
||||||
|
$entities
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsTesting(int $sourceId): void
|
||||||
|
{
|
||||||
|
$entity = $this->entityManager->find(ContentSourceEntity::class, $sourceId);
|
||||||
|
if (!$entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entity->setHealthStatus('testing')
|
||||||
|
->setHealthLastError(null);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->publishUpdate($sourceId, 'testing', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsHealthy(int $sourceId, \DateTimeImmutable $testedAt): void
|
||||||
|
{
|
||||||
|
$entity = $this->entityManager->find(ContentSourceEntity::class, $sourceId);
|
||||||
|
if (!$entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entity->setHealthStatus('ok')
|
||||||
|
->setHealthLastTestedAt($testedAt)
|
||||||
|
->setHealthLastError(null);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->publishUpdate($sourceId, 'ok', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsUnhealthy(int $sourceId, \DateTimeImmutable $testedAt, string $error): void
|
||||||
|
{
|
||||||
|
$entity = $this->entityManager->find(ContentSourceEntity::class, $sourceId);
|
||||||
|
if (!$entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entity->setHealthStatus('ko')
|
||||||
|
->setHealthLastTestedAt($testedAt)
|
||||||
|
->setHealthLastError($error);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->publishUpdate($sourceId, 'ko', $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publishUpdate(int $sourceId, string $status, ?string $error): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->hub->publish(new Update(
|
||||||
|
"scrapers/health/{$sourceId}",
|
||||||
|
json_encode(['sourceId' => $sourceId, 'status' => $status, 'error' => $error])
|
||||||
|
));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('Mercure publish failed for scraper health update', [
|
||||||
|
'sourceId' => $sourceId,
|
||||||
|
'status' => $status,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,21 @@ class ContentSource
|
|||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
private ?string $ChapterSelector = null;
|
private ?string $ChapterSelector = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $testSlug = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?float $testChapterNumber = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, options: ['default' => 'unknown'])]
|
||||||
|
private string $healthStatus = 'unknown';
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $healthLastTestedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $healthLastError = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -119,6 +134,66 @@ class ContentSource
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTestSlug(): ?string
|
||||||
|
{
|
||||||
|
return $this->testSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTestSlug(?string $testSlug): static
|
||||||
|
{
|
||||||
|
$this->testSlug = $testSlug;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTestChapterNumber(): ?float
|
||||||
|
{
|
||||||
|
return $this->testChapterNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTestChapterNumber(?float $testChapterNumber): static
|
||||||
|
{
|
||||||
|
$this->testChapterNumber = $testChapterNumber;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHealthStatus(): string
|
||||||
|
{
|
||||||
|
return $this->healthStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHealthStatus(string $healthStatus): static
|
||||||
|
{
|
||||||
|
$this->healthStatus = $healthStatus;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHealthLastTestedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->healthLastTestedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHealthLastTestedAt(?\DateTimeImmutable $healthLastTestedAt): static
|
||||||
|
{
|
||||||
|
$this->healthLastTestedAt = $healthLastTestedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHealthLastError(): ?string
|
||||||
|
{
|
||||||
|
return $this->healthLastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHealthLastError(?string $healthLastError): static
|
||||||
|
{
|
||||||
|
$this->healthLastError = $healthLastError;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCleanBaseUrl(): string
|
public function getCleanBaseUrl(): string
|
||||||
{
|
{
|
||||||
return preg_replace(
|
return preg_replace(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Scraping\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface;
|
||||||
|
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
|
||||||
|
|
||||||
|
class InMemoryContentSourceForHealthCheckRepository implements ContentSourceForHealthCheckInterface
|
||||||
|
{
|
||||||
|
/** @var ContentSourceHealthCheckData[] */
|
||||||
|
private array $sources = [];
|
||||||
|
|
||||||
|
public function add(ContentSourceHealthCheckData $data): void
|
||||||
|
{
|
||||||
|
$this->sources[] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(): array
|
||||||
|
{
|
||||||
|
return $this->sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->sources = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Scraping\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface;
|
||||||
|
|
||||||
|
class InMemoryContentSourceHealthRepository implements ContentSourceHealthRepositoryInterface
|
||||||
|
{
|
||||||
|
/** @var array<int, array{status: string, testedAt: ?\DateTimeImmutable, error: ?string}> */
|
||||||
|
private array $statuses = [];
|
||||||
|
|
||||||
|
public function markAsTesting(int $sourceId): void
|
||||||
|
{
|
||||||
|
$this->statuses[$sourceId] = ['status' => 'testing', 'testedAt' => null, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsHealthy(int $sourceId, \DateTimeImmutable $testedAt): void
|
||||||
|
{
|
||||||
|
$this->statuses[$sourceId] = ['status' => 'ok', 'testedAt' => $testedAt, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsUnhealthy(int $sourceId, \DateTimeImmutable $testedAt, string $error): void
|
||||||
|
{
|
||||||
|
$this->statuses[$sourceId] = ['status' => 'ko', 'testedAt' => $testedAt, 'error' => $error];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(int $sourceId): ?string
|
||||||
|
{
|
||||||
|
return $this->statuses[$sourceId]['status'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getError(int $sourceId): ?string
|
||||||
|
{
|
||||||
|
return $this->statuses[$sourceId]['error'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->statuses = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Scraping\Application\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Application\Command\CheckAllScrapersHealth;
|
||||||
|
use App\Domain\Scraping\Application\CommandHandler\CheckAllScrapersHealthHandler;
|
||||||
|
use App\Domain\Scraping\Application\CommandHandler\TestScraperConfigurationHandler;
|
||||||
|
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
|
||||||
|
use App\Tests\Domain\Scraping\Adapter\InMemoryContentSourceForHealthCheckRepository;
|
||||||
|
use App\Tests\Domain\Scraping\Adapter\InMemoryContentSourceHealthRepository;
|
||||||
|
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperAdapter;
|
||||||
|
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
class CheckAllScrapersHealthHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private InMemoryContentSourceForHealthCheckRepository $sourceRepo;
|
||||||
|
private InMemoryContentSourceHealthRepository $healthRepo;
|
||||||
|
private InMemoryScraperFactory $scraperFactory;
|
||||||
|
private CheckAllScrapersHealthHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->sourceRepo = new InMemoryContentSourceForHealthCheckRepository();
|
||||||
|
$this->healthRepo = new InMemoryContentSourceHealthRepository();
|
||||||
|
$this->scraperFactory = new InMemoryScraperFactory();
|
||||||
|
$this->scraperFactory->addScraper('html', new InMemoryScraperAdapter());
|
||||||
|
|
||||||
|
$testScraperHandler = new TestScraperConfigurationHandler($this->scraperFactory);
|
||||||
|
|
||||||
|
$this->handler = new CheckAllScrapersHealthHandler(
|
||||||
|
$this->sourceRepo,
|
||||||
|
$this->healthRepo,
|
||||||
|
$testScraperHandler,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSourceWithoutTestSlugIsSkipped(): void
|
||||||
|
{
|
||||||
|
$this->sourceRepo->add(new ContentSourceHealthCheckData(
|
||||||
|
id: 1,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
chapterUrlFormat: 'https://example.com/{slug}/{chapterNumber}',
|
||||||
|
scrapingType: 'html',
|
||||||
|
imageSelector: 'img',
|
||||||
|
nextPageSelector: null,
|
||||||
|
chapterSelector: null,
|
||||||
|
testSlug: null,
|
||||||
|
testChapterNumber: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->handler->handle(new CheckAllScrapersHealth());
|
||||||
|
|
||||||
|
$this->assertNull($this->healthRepo->getStatus(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSourceWithTestSlugIsMarkedAsHealthyOnSuccess(): void
|
||||||
|
{
|
||||||
|
$this->sourceRepo->add(new ContentSourceHealthCheckData(
|
||||||
|
id: 2,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
chapterUrlFormat: 'https://example.com/{slug}/{chapterNumber}',
|
||||||
|
scrapingType: 'html',
|
||||||
|
imageSelector: 'img',
|
||||||
|
nextPageSelector: null,
|
||||||
|
chapterSelector: null,
|
||||||
|
testSlug: 'one-piece',
|
||||||
|
testChapterNumber: 1.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->handler->handle(new CheckAllScrapersHealth());
|
||||||
|
|
||||||
|
$this->assertSame('ok', $this->healthRepo->getStatus(2));
|
||||||
|
$this->assertNull($this->healthRepo->getError(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSourceIsMarkedAsUnhealthyWhenScraperThrows(): void
|
||||||
|
{
|
||||||
|
$failingScraper = new InMemoryScraperAdapter();
|
||||||
|
$failingScraper->simulateError(new \RuntimeException('Connexion refusée'));
|
||||||
|
$this->scraperFactory->addScraper('html', $failingScraper);
|
||||||
|
|
||||||
|
$this->sourceRepo->add(new ContentSourceHealthCheckData(
|
||||||
|
id: 3,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
chapterUrlFormat: 'https://example.com/{slug}/{chapterNumber}',
|
||||||
|
scrapingType: 'html',
|
||||||
|
imageSelector: 'img',
|
||||||
|
nextPageSelector: null,
|
||||||
|
chapterSelector: null,
|
||||||
|
testSlug: 'one-piece',
|
||||||
|
testChapterNumber: 1.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->handler->handle(new CheckAllScrapersHealth());
|
||||||
|
|
||||||
|
$this->assertSame('ko', $this->healthRepo->getStatus(3));
|
||||||
|
$this->assertNotNull($this->healthRepo->getError(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultipleSourcesAreAllProcessed(): void
|
||||||
|
{
|
||||||
|
$this->sourceRepo->add(new ContentSourceHealthCheckData(
|
||||||
|
id: 10,
|
||||||
|
baseUrl: 'https://siteA.com',
|
||||||
|
chapterUrlFormat: 'https://siteA.com/{slug}/{chapterNumber}',
|
||||||
|
scrapingType: 'html',
|
||||||
|
imageSelector: 'img',
|
||||||
|
nextPageSelector: null,
|
||||||
|
chapterSelector: null,
|
||||||
|
testSlug: 'manga-a',
|
||||||
|
testChapterNumber: 1.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->sourceRepo->add(new ContentSourceHealthCheckData(
|
||||||
|
id: 11,
|
||||||
|
baseUrl: 'https://siteB.com',
|
||||||
|
chapterUrlFormat: 'https://siteB.com/{slug}/{chapterNumber}',
|
||||||
|
scrapingType: 'html',
|
||||||
|
imageSelector: 'img',
|
||||||
|
nextPageSelector: null,
|
||||||
|
chapterSelector: null,
|
||||||
|
testSlug: null,
|
||||||
|
testChapterNumber: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->sourceRepo->add(new ContentSourceHealthCheckData(
|
||||||
|
id: 12,
|
||||||
|
baseUrl: 'https://siteC.com',
|
||||||
|
chapterUrlFormat: 'https://siteC.com/{slug}/{chapterNumber}',
|
||||||
|
scrapingType: 'html',
|
||||||
|
imageSelector: 'img',
|
||||||
|
nextPageSelector: null,
|
||||||
|
chapterSelector: null,
|
||||||
|
testSlug: 'manga-c',
|
||||||
|
testChapterNumber: 3.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->handler->handle(new CheckAllScrapersHealth());
|
||||||
|
|
||||||
|
$this->assertSame('ok', $this->healthRepo->getStatus(10));
|
||||||
|
$this->assertNull($this->healthRepo->getStatus(11)); // skippée
|
||||||
|
$this->assertSame('ok', $this->healthRepo->getStatus(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->sourceRepo->clear();
|
||||||
|
$this->healthRepo->clear();
|
||||||
|
$this->scraperFactory->clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
|||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||||
|
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
|
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\InMemoryScraperFactory;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
|
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
|
||||||
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class ScrapeChapterHandlerTest extends TestCase
|
class ScrapeChapterHandlerTest extends TestCase
|
||||||
@@ -30,7 +29,6 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
private InMemoryMangaRepository $mangaRepository;
|
private InMemoryMangaRepository $mangaRepository;
|
||||||
private InMemorySourceRepository $sourceRepository;
|
private InMemorySourceRepository $sourceRepository;
|
||||||
private InMemoryEventBus $eventBus;
|
private InMemoryEventBus $eventBus;
|
||||||
private EntityManagerInterface|MockObject $entityManager;
|
|
||||||
private ScrapeChapterHandler $handler;
|
private ScrapeChapterHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -43,11 +41,6 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
$this->mangaRepository = new InMemoryMangaRepository();
|
$this->mangaRepository = new InMemoryMangaRepository();
|
||||||
$this->sourceRepository = new InMemorySourceRepository();
|
$this->sourceRepository = new InMemorySourceRepository();
|
||||||
$this->eventBus = new InMemoryEventBus();
|
$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(
|
$this->chapterRepository->save(new Chapter(
|
||||||
id: '1',
|
id: '1',
|
||||||
@@ -65,26 +58,27 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
$this->mangaRepository,
|
$this->mangaRepository,
|
||||||
$this->sourceRepository,
|
$this->sourceRepository,
|
||||||
$this->eventBus,
|
$this->eventBus,
|
||||||
$this->entityManager
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHandleSuccessfully(): void
|
public function testHandleSuccessfully(): void
|
||||||
{
|
{
|
||||||
$command = new ScrapeChapter(
|
$jobId = 'test-job-id';
|
||||||
chapterId: '1'
|
$job = new ScrapingJob($jobId, 'test-manga', 2);
|
||||||
);
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
|
$command = new ScrapeChapter(chapterId: '1', jobId: $jobId);
|
||||||
$this->handler->handle($command);
|
$this->handler->handle($command);
|
||||||
|
|
||||||
$job = $this->jobRepository->findByType('scraping_job');
|
$jobs = $this->jobRepository->findByType('scraping_job');
|
||||||
$this->assertCount(1, $job);
|
$this->assertCount(1, $jobs);
|
||||||
$job = array_values($job)[0];
|
$job = array_values($jobs)[0];
|
||||||
|
|
||||||
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
|
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
|
||||||
$this->assertCount(2, $dispatchedMessages);
|
$this->assertCount(2, $dispatchedMessages);
|
||||||
|
|
||||||
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
|
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
|
||||||
|
$this->assertSame($jobId, $dispatchedMessages[0]->getJobId());
|
||||||
$this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber());
|
$this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber());
|
||||||
|
|
||||||
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]);
|
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]);
|
||||||
|
|||||||
72
tests/Feature/Scraping/CheckAllScrapersHealthTest.php
Normal file
72
tests/Feature/Scraping/CheckAllScrapersHealthTest.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Feature\Scraping;
|
||||||
|
|
||||||
|
use App\Entity\ContentSource;
|
||||||
|
use App\Tests\Feature\AbstractApiTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||||
|
|
||||||
|
final class CheckAllScrapersHealthTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
use ResetDatabase;
|
||||||
|
|
||||||
|
private function post(): void
|
||||||
|
{
|
||||||
|
static::createClient()->request('POST', '/api/scraping/check-all-health', [
|
||||||
|
'json' => new \stdClass(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReturns202WithNoSources(): void
|
||||||
|
{
|
||||||
|
$this->post();
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(Response::HTTP_ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReturns202WithSourcesHavingNoTestConfig(): void
|
||||||
|
{
|
||||||
|
$source = new ContentSource();
|
||||||
|
$source->setBaseUrl('https://example.com')
|
||||||
|
->setChapterUrlFormat('https://example.com/{slug}/{chapterNumber}')
|
||||||
|
->setScrapingType('html');
|
||||||
|
|
||||||
|
$this->entityManager->persist($source);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->post();
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(Response::HTTP_ACCEPTED);
|
||||||
|
|
||||||
|
// La source sans testSlug ne doit pas avoir son statut modifié
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$reloaded = $this->entityManager->find(ContentSource::class, $source->getId());
|
||||||
|
$this->assertSame('unknown', $reloaded->getHealthStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHealthStatusIsUpdatedForSourcesWithTestConfig(): void
|
||||||
|
{
|
||||||
|
$source = new ContentSource();
|
||||||
|
$source->setBaseUrl('https://example.com')
|
||||||
|
->setChapterUrlFormat('https://example.com/{slug}/{chapterNumber}')
|
||||||
|
->setScrapingType('html')
|
||||||
|
->setTestSlug('one-piece')
|
||||||
|
->setTestChapterNumber(1.0);
|
||||||
|
|
||||||
|
$this->entityManager->persist($source);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->post();
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(Response::HTTP_ACCEPTED);
|
||||||
|
|
||||||
|
// Le statut ne doit plus être 'unknown' après le test
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$reloaded = $this->entityManager->find(ContentSource::class, $source->getId());
|
||||||
|
$this->assertNotSame('unknown', $reloaded->getHealthStatus());
|
||||||
|
$this->assertNotSame('testing', $reloaded->getHealthStatus()); // doit être terminé
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,13 +35,14 @@ class ScrapeChapterTest extends AbstractApiTestCase
|
|||||||
// Then
|
// Then
|
||||||
$this->assertResponseStatusCodeSame(202);
|
$this->assertResponseStatusCodeSame(202);
|
||||||
|
|
||||||
$messages = $this->messageBus->getDispatchedMessages();
|
$messages = InMemoryMessageBus::$messages;
|
||||||
$this->assertCount(1, $messages, 'Un message devrait être dispatché');
|
$this->assertCount(1, $messages, 'Un message devrait être dispatché');
|
||||||
|
|
||||||
/** @var ScrapeChapter $message */
|
/** @var ScrapeChapter $message */
|
||||||
$message = $messages[0];
|
$message = $messages[0];
|
||||||
$this->assertInstanceOf(ScrapeChapter::class, $message);
|
$this->assertInstanceOf(ScrapeChapter::class, $message);
|
||||||
$this->assertEquals('chapter-123', $message->chapterId);
|
$this->assertEquals('chapter-123', $message->chapterId);
|
||||||
|
$this->assertNotEmpty($message->jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInitiateChapterScrapingWithInvalidPayload(): void
|
public function testInitiateChapterScrapingWithInvalidPayload(): void
|
||||||
@@ -72,6 +73,6 @@ class ScrapeChapterTest extends AbstractApiTestCase
|
|||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
$this->messageBus->clear();
|
InMemoryMessageBus::$messages = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
tests/Feature/Setting/DeleteContentSourceTest.php
Normal file
50
tests/Feature/Setting/DeleteContentSourceTest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Feature\Setting;
|
||||||
|
|
||||||
|
use App\Entity\ContentSource;
|
||||||
|
use App\Tests\Feature\AbstractApiTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||||
|
|
||||||
|
final class DeleteContentSourceTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
use ResetDatabase;
|
||||||
|
|
||||||
|
private int $sourceId;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$source = new ContentSource();
|
||||||
|
$source->setBaseUrl('https://mangadex.org')
|
||||||
|
->setChapterUrlFormat('https://mangadex.org/chapter/{id}')
|
||||||
|
->setScrapingType('html');
|
||||||
|
|
||||||
|
$this->entityManager->persist($source);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->sourceId = $source->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItDeletesSourceSuccessfully(): void
|
||||||
|
{
|
||||||
|
static::createClient()->request('DELETE', "/api/content-sources/{$this->sourceId}");
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT);
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$deletedSource = $this->entityManager->find(ContentSource::class, $this->sourceId);
|
||||||
|
$this->assertNull($deletedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReturnsNotFoundWhenSourceDoesNotExist(): void
|
||||||
|
{
|
||||||
|
static::createClient()->request('DELETE', '/api/content-sources/999999');
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user