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:
parent
b0ce36096f
commit
795cbeccc3
@@ -23,7 +23,11 @@ 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,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -174,6 +178,36 @@ export const useContentSourceStore = defineStore('contentSource', {
|
|||||||
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 +215,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,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
|
* Teste une configuration de scraper
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,6 +30,14 @@
|
|||||||
class="px-2 py-1 text-xs font-medium">
|
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>
|
||||||
|
|||||||
@@ -108,17 +108,17 @@
|
|||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
<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">
|
<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-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>
|
||||||
|
|
||||||
<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 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" />
|
||||||
@@ -126,11 +126,11 @@
|
|||||||
|
|
||||||
<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">
|
||||||
Numéro de chapitre
|
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 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"
|
||||||
@@ -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">
|
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>Lancer le test</span>
|
<span>Tester maintenant</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,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);
|
||||||
@@ -204,17 +201,17 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.source, (newSource) => {
|
watch(() => props.source, (newSource) => {
|
||||||
@@ -226,7 +223,9 @@ 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 {
|
||||||
form.value = {
|
form.value = {
|
||||||
@@ -236,7 +235,9 @@ watch(() => props.source, (newSource) => {
|
|||||||
nextPageSelector: '',
|
nextPageSelector: '',
|
||||||
chapterSelector: '',
|
chapterSelector: '',
|
||||||
scrapingType: 'html',
|
scrapingType: 'html',
|
||||||
token: ''
|
token: '',
|
||||||
|
testSlug: '',
|
||||||
|
testChapterNumber: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
@@ -253,8 +254,9 @@ const testConfiguration = async () => {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -91,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';
|
||||||
@@ -108,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);
|
||||||
@@ -120,12 +125,32 @@ 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
|
||||||
@@ -135,6 +160,7 @@ const toolbarConfig = computed(() => ({
|
|||||||
],
|
],
|
||||||
rightSection: [
|
rightSection: [
|
||||||
{ type: 'button', icon: ArrowPathIcon, label: 'Actualiser', onClick: () => contentSourceStore.loadSources(), disabled: loadingSources.value },
|
{ 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: ArrowDownTrayIcon, label: 'Exporter', onClick: handleExport, disabled: exporting.value },
|
||||||
{ type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
|
{ type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
|
||||||
],
|
],
|
||||||
@@ -156,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();
|
||||||
|
|||||||
@@ -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: ~
|
||||||
|
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user