feat(system): implémenter la page Logs des erreurs de scraping
- Nouveau domaine `system` avec `logsStore` (Pinia) filtré sur status=failed&type=scraping_job, tri, pagination et suppression - Composant `LogItem` : affiche titre manga, chapitre, date, durée, domaine source (lien vers page d'édition), badge type scraping, slug utilisé, message d'erreur expandable - Page `LogsPage` : toolbar avec badge total, dropdown tri, rafraîchir, tout supprimer ; charge les ContentSources pour enrichir l'affichage - Route /system/logs branchée sur LogsPage - ApiJobRepository : ajout du paramètre `type` dans getJobs - Job entity : ajout des champs startedAt et completedAt
This commit is contained in:
parent
36f873aaca
commit
670e3f5315
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>
|
||||
Reference in New Issue
Block a user