Files
Mangarr/assets/vue/app/domain/system/presentation/components/LogItem.vue
ext.jeremy.guillot@maxicoffee.domains 670e3f5315 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
2026-03-16 14:43:19 +01:00

123 lines
4.9 KiB
Vue

<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>