feat(setting): étendre ContentSource avec champs de test et domain model

- Ajouter testSlug, testChapterNumber, baseUrl sur ContentSource (entité, domain model, migration)
- Exposer ces champs dans les Resources, Processors, Providers et Mapper
- Mettre à jour store Pinia, repository API et composants Vue (form, card, liste)
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-16 00:08:50 +01:00
parent b0ce36096f
commit 795cbeccc3
20 changed files with 475 additions and 30 deletions

View File

@@ -23,7 +23,11 @@ export const useContentSourceStore = defineStore('contentSource', {
importing: false,
exporting: false,
importError: null,
exportError: null
exportError: null,
// Health check state
checkingHealth: false,
checkHealthError: null,
}),
getters: {
@@ -174,6 +178,36 @@ export const useContentSourceStore = defineStore('contentSource', {
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
clearErrors() {
this.sourcesError = null;
@@ -181,6 +215,7 @@ export const useContentSourceStore = defineStore('contentSource', {
this.saveError = null;
this.importError = null;
this.exportError = null;
this.checkHealthError = null;
}
}
});

View File

@@ -0,0 +1,6 @@
export const ScraperHealthStatus = {
UNKNOWN: 'unknown',
OK: 'ok',
KO: 'ko',
TESTING: 'testing',
};

View File

@@ -82,6 +82,17 @@ 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');
}
}
/**
* Teste une configuration de scraper
*/

View File

@@ -30,6 +30,14 @@
class="px-2 py-1 text-xs font-medium">
{{ getOrientation(source) }}
</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>
@@ -39,6 +47,7 @@
<script setup>
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
import { ScraperHealthStatus } from '../../domain/model/ScraperHealthStatus';
defineProps({
source: {
@@ -86,4 +95,26 @@ const getOrientationBadgeClass = (source) => {
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>

View File

@@ -108,17 +108,17 @@
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center space-x-2 mb-6">
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Test de la configuration</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Configuration de test (health check)</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Manga Slug
<label for="testSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Manga Slug <span class="text-gray-500">(enregistré)</span>
</label>
<input
id="testMangaSlug"
v-model="testData.mangaSlug"
id="testSlug"
v-model="form.testSlug"
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="manga-slug" />
@@ -126,11 +126,11 @@
<div>
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Numéro de chapitre
Numéro de chapitre <span class="text-gray-500">(enregistré)</span>
</label>
<input
id="testChapterNumber"
v-model="testData.chapterNumber"
v-model="form.testChapterNumber"
type="number"
step="0.1"
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"
@@ -151,7 +151,7 @@
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" />
<PlayIcon v-else class="w-4 h-4" />
<span>Lancer le test</span>
<span>Tester maintenant</span>
</button>
</div>
</div>
@@ -191,12 +191,9 @@ const form = ref({
nextPageSelector: '',
chapterSelector: '',
scrapingType: 'html',
token: ''
});
const testData = ref({
mangaSlug: '',
chapterNumber: ''
token: '',
testSlug: '',
testChapterNumber: '',
});
const testing = ref(false);
@@ -204,17 +201,17 @@ const testing = ref(false);
const canTest = computed(() => {
return form.value.baseUrl &&
form.value.chapterUrlFormat &&
testData.value.mangaSlug &&
testData.value.chapterNumber;
form.value.testSlug &&
form.value.testChapterNumber;
});
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 form.value.chapterUrlFormat
.replace('{slug}', testData.value.mangaSlug)
.replace('{chapterNumber}', testData.value.chapterNumber);
.replace('{slug}', form.value.testSlug)
.replace('{chapterNumber}', form.value.testChapterNumber);
});
watch(() => props.source, (newSource) => {
@@ -226,7 +223,9 @@ watch(() => props.source, (newSource) => {
nextPageSelector: newSource.nextPageSelector || '',
chapterSelector: newSource.chapterSelector || '',
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
token: newSource.token || ''
token: newSource.token || '',
testSlug: newSource.testSlug || '',
testChapterNumber: newSource.testChapterNumber ?? '',
};
} else {
form.value = {
@@ -236,7 +235,9 @@ watch(() => props.source, (newSource) => {
nextPageSelector: '',
chapterSelector: '',
scrapingType: 'html',
token: ''
token: '',
testSlug: '',
testChapterNumber: '',
};
}
}, { immediate: true });
@@ -253,8 +254,9 @@ const testConfiguration = async () => {
await emit('test', {
configuration: { ...form.value },
testData: {
...testData.value,
testUrl: generatedTestUrl.value
mangaSlug: form.value.testSlug,
chapterNumber: form.value.testChapterNumber,
testUrl: generatedTestUrl.value,
}
});
} finally {

View File

@@ -91,10 +91,11 @@ import {
ArrowPathIcon,
ArrowUpTrayIcon,
ExclamationTriangleIcon,
HeartIcon,
PlusIcon
} from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore';
@@ -108,9 +109,13 @@ const {
loadingSources,
sourcesError,
importing,
exporting
exporting,
checkingHealth,
} = storeToRefs(contentSourceStore);
// Mercure — écoute des mises à jour health
let mercureEventSource = null;
// Local state
const showImportModal = ref(false);
const showExportSuccess = ref(false);
@@ -120,12 +125,32 @@ const importData = ref('');
// Load sources on mount and clear current source
onMounted(async () => {
try {
contentSourceStore.clearCurrentSource(); // Clear any previously loaded source
contentSourceStore.clearErrors(); // Clear any previous errors
contentSourceStore.clearCurrentSource();
contentSourceStore.clearErrors();
await contentSourceStore.loadSources();
} catch (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
@@ -135,6 +160,7 @@ const toolbarConfig = computed(() => ({
],
rightSection: [
{ type: 'button', icon: ArrowPathIcon, label: 'Actualiser', onClick: () => contentSourceStore.loadSources(), disabled: loadingSources.value },
{ type: 'button', icon: HeartIcon, label: 'Tester tous', onClick: handleCheckAllHealth, disabled: checkingHealth.value },
{ type: 'button', icon: ArrowDownTrayIcon, label: 'Exporter', onClick: handleExport, disabled: exporting.value },
{ type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
],
@@ -156,6 +182,14 @@ const openSourceLink = (url) => {
window.open(url, '_blank');
};
async function handleCheckAllHealth() {
try {
await contentSourceStore.checkAllHealth();
} catch (error) {
console.error('Erreur lors du health check:', error);
}
}
async function handleExport() {
try {
const exportData = await contentSourceStore.exportSources();