feat(system): implémenter la page Logs des erreurs de scraping #26
@@ -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) {
|
||||||
|
|||||||
93
assets/vue/app/domain/system/application/store/logsStore.js
Normal file
93
assets/vue/app/domain/system/application/store/logsStore.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiJobRepository } from '../../../activity/infrastructure/api/ApiJobRepository';
|
||||||
|
|
||||||
|
const jobRepository = new ApiJobRepository();
|
||||||
|
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
|
||||||
|
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: ['failed'],
|
||||||
|
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 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: 'failed', type: 'scraping_job' });
|
||||||
|
await this.loadLogs(1);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
122
assets/vue/app/domain/system/presentation/components/LogItem.vue
Normal file
122
assets/vue/app/domain/system/presentation/components/LogItem.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 py-4 px-6">
|
||||||
|
<!-- Ligne 1 : Titre manga + chapitre + 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>
|
||||||
|
</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>
|
||||||
151
assets/vue/app/domain/system/presentation/pages/LogsPage.vue
Normal file
151
assets/vue/app/domain/system/presentation/pages/LogsPage.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<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,
|
||||||
|
} = 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 toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Logs', class: 'text-sm font-medium' },
|
||||||
|
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'dropdown',
|
||||||
|
icon: BarsArrowDownIcon,
|
||||||
|
label: 'Trier',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Plus récent',
|
||||||
|
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||||
|
onClick: () => 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>
|
||||||
@@ -10,6 +10,7 @@ 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 Layout from '../shared/components/layout/Layout.vue';
|
import Layout from '../shared/components/layout/Layout.vue';
|
||||||
|
|
||||||
// Placeholder component for new routes
|
// Placeholder component for new routes
|
||||||
@@ -148,8 +149,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/system/logs',
|
path: '/system/logs',
|
||||||
name: 'system-logs',
|
name: 'system-logs',
|
||||||
component: PlaceholderComponent,
|
component: LogsPage,
|
||||||
props: { title: 'Journaux système' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/updates',
|
path: '/system/updates',
|
||||||
|
|||||||
Reference in New Issue
Block a user