Add ntfy push notification system with comprehensive configuration, API endpoints, and automatic event notifications for tasks, schedules, health checks, and bootstrap operations

This commit is contained in:
2025-12-06 17:37:23 -05:00
parent 123ca2cc08
commit 3559552eca
30 changed files with 4324 additions and 254 deletions
+773 -144
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -1946,6 +1946,23 @@
<!-- Grille de dates -->
<div id="task-cal-grid" class="grid grid-cols-7 text-sm pb-2 px-1"></div>
<!-- Filtrage par heure -->
<div class="px-3 py-2 border-t border-gray-800">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-2">
<i class="fas fa-clock"></i>
<span>Plage horaire (optionnel)</span>
</div>
<div class="flex items-center gap-2">
<input type="time" id="task-cal-hour-start"
class="flex-1 px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 focus:border-purple-500 focus:outline-none"
placeholder="Début">
<span class="text-gray-500 text-xs">à</span>
<input type="time" id="task-cal-hour-end"
class="flex-1 px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 focus:border-purple-500 focus:outline-none"
placeholder="Fin">
</div>
</div>
<!-- Footer -->
<div class="flex justify-between items-center gap-2 mt-1 pt-2 border-t border-gray-800 px-3 pb-3">
<button id="task-cal-clear" type="button"
+361 -58
View File
@@ -24,12 +24,21 @@ class DashboardManager {
// Sélection de dates via le calendrier (liste de chaînes YYYY-MM-DD)
this.selectedTaskDates = [];
this.taskCalendarMonth = new Date();
// Filtres d'heure
this.currentHourStart = '';
this.currentHourEnd = '';
// Filtre par type de source (scheduled, manual, adhoc)
this.currentSourceTypeFilter = 'all';
this.currentGroupFilter = 'all';
this.currentBootstrapFilter = 'all';
this.currentCategoryFilter = 'all';
this.currentSubcategoryFilter = 'all';
this.currentTargetFilter = 'all';
// Pagination côté serveur
this.tasksTotalCount = 0;
this.tasksHasMore = false;
// Groupes pour la gestion des hôtes
this.envGroups = [];
this.roleGroups = [];
@@ -1124,9 +1133,9 @@ class DashboardManager {
// Modal pour exécuter un playbook sur un hôte spécifique
async showPlaybookModalForHost(hostName) {
// Récupérer la liste des playbooks disponibles
// Récupérer la liste des playbooks compatibles avec cet hôte
try {
const pbResult = await this.apiCall('/api/ansible/playbooks');
const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(hostName)}`);
const playbooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : [];
const playbookOptions = playbooks.map(p => `
@@ -1143,6 +1152,11 @@ class DashboardManager {
</div>
</div>
<div class="p-3 bg-blue-900/30 border border-blue-600 rounded-lg text-sm">
<i class="fas fa-info-circle text-blue-400 mr-2"></i>
Seuls les playbooks compatibles avec cet hôte sont affichés (${playbooks.length} disponible${playbooks.length > 1 ? 's' : ''})
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-play-circle mr-2"></i>Playbook à exécuter
@@ -1880,19 +1894,30 @@ class DashboardManager {
}
}
executePlaybookOnGroup() {
async executePlaybookOnGroup() {
const currentGroup = this.currentGroupFilter;
// Charger les playbooks compatibles avec ce groupe
let compatiblePlaybooks = [];
try {
const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(currentGroup)}`);
compatiblePlaybooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : [];
} catch (error) {
this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error');
return;
}
// Générer la liste des playbooks groupés par catégorie
const categoryColors = {
'maintenance': 'text-orange-400',
'monitoring': 'text-green-400',
'backup': 'text-blue-400',
'general': 'text-gray-400'
'general': 'text-gray-400',
'testing': 'text-purple-400'
};
let playbooksByCategory = {};
this.playbooks.forEach(pb => {
compatiblePlaybooks.forEach(pb => {
const cat = pb.category || 'general';
if (!playbooksByCategory[cat]) playbooksByCategory[cat] = [];
playbooksByCategory[cat].push(pb);
@@ -1930,6 +1955,10 @@ class DashboardManager {
<i class="fas fa-info-circle text-purple-400 mr-2"></i>
Sélectionnez un playbook à exécuter sur <strong>${currentGroup === 'all' ? 'tous les hôtes' : 'le groupe ' + currentGroup}</strong>
</div>
<div class="p-3 bg-blue-900/30 border border-blue-600 rounded-lg text-sm">
<i class="fas fa-filter text-blue-400 mr-2"></i>
Seuls les playbooks compatibles avec ce ${currentGroup === 'all' ? 'groupe' : 'groupe'} sont affichés (${compatiblePlaybooks.length} disponible${compatiblePlaybooks.length > 1 ? 's' : ''})
</div>
<div class="max-h-96 overflow-y-auto">
${playbooksHtml || '<p class="text-gray-500 text-center py-4">Aucun playbook disponible</p>'}
</div>
@@ -2037,9 +2066,24 @@ class DashboardManager {
`<option value="${cat}" ${this.currentCategoryFilter === cat ? 'selected' : ''}>${cat}</option>`
).join('');
// Types de source pour le filtre
const sourceTypes = [
{ value: 'scheduled', label: 'Planifiés' },
{ value: 'manual', label: 'Manuels' },
{ value: 'adhoc', label: 'Ad-hoc' }
];
const sourceTypeOptions = sourceTypes.map(st =>
`<option value="${st.value}" ${this.currentSourceTypeFilter === st.value ? 'selected' : ''}>${st.label}</option>`
).join('');
// Vérifier si des filtres sont actifs
const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') ||
(this.currentCategoryFilter && this.currentCategoryFilter !== 'all');
(this.currentCategoryFilter && this.currentCategoryFilter !== 'all') ||
(this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') ||
(this.currentHourStart || this.currentHourEnd);
// Labels pour les types de source
const sourceTypeLabels = { scheduled: 'Planifiés', manual: 'Manuels', adhoc: 'Ad-hoc' };
// Générer les badges de filtres actifs
const activeFiltersHtml = hasActiveFilters ? `
@@ -2061,6 +2105,22 @@ class DashboardManager {
</button>
</span>
` : ''}
${this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all' ? `
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-green-600/30 text-green-300 border border-green-500/50">
<i class="fas fa-clock mr-1"></i>${sourceTypeLabels[this.currentSourceTypeFilter] || this.currentSourceTypeFilter}
<button onclick="dashboard.clearSourceTypeFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
<i class="fas fa-times"></i>
</button>
</span>
` : ''}
${this.currentHourStart || this.currentHourEnd ? `
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-yellow-600/30 text-yellow-300 border border-yellow-500/50">
<i class="fas fa-clock mr-1"></i>${this.currentHourStart || '00:00'} - ${this.currentHourEnd || '23:59'}
<button onclick="dashboard.clearHourFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
<i class="fas fa-times"></i>
</button>
</span>
` : ''}
<button onclick="dashboard.clearAllTaskFilters()" class="text-xs text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times-circle mr-1"></i>Tout effacer
</button>
@@ -2097,6 +2157,11 @@ class DashboardManager {
<option value="all" ${this.currentCategoryFilter === 'all' || !this.currentCategoryFilter ? 'selected' : ''}>Toutes catégories</option>
${taskCategoryOptions}
</select>
<select id="task-source-type-filter" onchange="dashboard.filterTasksBySourceType(this.value)"
class="px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-xs">
<option value="all" ${this.currentSourceTypeFilter === 'all' || !this.currentSourceTypeFilter ? 'selected' : ''}>Tous types</option>
${sourceTypeOptions}
</select>
<button onclick="dashboard.showAdHocConsole()" class="px-3 py-1.5 bg-purple-600 text-xs rounded hover:bg-purple-500 transition-colors">
<i class="fas fa-terminal mr-1"></i>Console Ad-Hoc
</button>
@@ -2128,20 +2193,18 @@ class DashboardManager {
logsSection.id = 'task-logs-section';
logsSection.innerHTML = '<h4 class="text-sm font-semibold text-gray-400 mb-2 mt-4"><i class="fas fa-history mr-2"></i>Historique des tâches</h4>';
// Utiliser le compteur de pagination
const displayCount = Math.min(this.tasksDisplayedCount, filteredLogs.length);
filteredLogs.slice(0, displayCount).forEach(log => {
// Afficher tous les logs chargés (pagination côté serveur)
filteredLogs.forEach(log => {
logsSection.appendChild(this.createTaskLogCard(log));
});
container.appendChild(logsSection);
// Afficher la pagination si nécessaire
// Afficher la pagination si nécessaire (basée sur la pagination serveur)
const paginationEl = document.getElementById('tasks-pagination');
if (paginationEl) {
if (filteredLogs.length > this.tasksDisplayedCount) {
if (this.tasksHasMore) {
paginationEl.classList.remove('hidden');
// Mettre à jour le texte du bouton avec le nombre restant
const remaining = filteredLogs.length - this.tasksDisplayedCount;
const remaining = this.tasksTotalCount - filteredLogs.length;
paginationEl.innerHTML = `
<button onclick="dashboard.loadMoreTasks()" class="px-6 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 text-sm">
<i class="fas fa-chevron-down mr-2"></i>Charger plus (${remaining} restantes)
@@ -2162,45 +2225,76 @@ class DashboardManager {
}
}
loadMoreTasks() {
// Augmenter le compteur de tâches affichées
this.tasksDisplayedCount += this.tasksPerPage;
// Filtrer les logs selon les filtres actifs
let filteredLogs = this.taskLogs;
async loadMoreTasks() {
// Charger plus de tâches depuis le serveur (pagination côté serveur)
const params = new URLSearchParams();
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
filteredLogs = filteredLogs.filter(log => log.status === this.currentStatusFilter);
params.append('status', this.currentStatusFilter);
}
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
params.append('year', String(firstDate.getFullYear()));
params.append('month', String(firstDate.getMonth() + 1).padStart(2, '0'));
params.append('day', String(firstDate.getDate()).padStart(2, '0'));
} else {
if (this.currentDateFilter.year) params.append('year', this.currentDateFilter.year);
if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month);
if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day);
}
if (this.currentHourStart) params.append('hour_start', this.currentHourStart);
if (this.currentHourEnd) params.append('hour_end', this.currentHourEnd);
if (this.currentTargetFilter && this.currentTargetFilter !== 'all') {
params.append('target', this.currentTargetFilter);
}
if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') {
params.append('category', this.currentCategoryFilter);
}
if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') {
params.append('source_type', this.currentSourceTypeFilter);
}
// Récupérer la section des logs
const logsSection = document.getElementById('task-logs-section');
if (!logsSection) {
// Si la section n'existe pas, re-render tout
this.renderTasks();
return;
}
// Pagination: charger la page suivante
params.append('limit', this.tasksPerPage);
params.append('offset', this.taskLogs.length);
// Ajouter les nouvelles tâches
const startIndex = this.tasksDisplayedCount - this.tasksPerPage;
const endIndex = Math.min(this.tasksDisplayedCount, filteredLogs.length);
for (let i = startIndex; i < endIndex; i++) {
logsSection.appendChild(this.createTaskLogCard(filteredLogs[i]));
}
// Mettre à jour le bouton de pagination
const paginationEl = document.getElementById('tasks-pagination');
if (paginationEl) {
if (filteredLogs.length > this.tasksDisplayedCount) {
const remaining = filteredLogs.length - this.tasksDisplayedCount;
paginationEl.innerHTML = `
<button onclick="dashboard.loadMoreTasks()" class="px-6 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 text-sm">
<i class="fas fa-chevron-down mr-2"></i>Charger plus (${remaining} restantes)
</button>
`;
} else {
paginationEl.classList.add('hidden');
try {
const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`);
const newLogs = result.logs || [];
// Ajouter les nouveaux logs à la liste existante
this.taskLogs = [...this.taskLogs, ...newLogs];
this.tasksTotalCount = result.total_count || this.tasksTotalCount;
this.tasksHasMore = result.has_more || false;
this.tasksDisplayedCount = this.taskLogs.length;
// Récupérer la section des logs
const logsSection = document.getElementById('task-logs-section');
if (!logsSection) {
this.renderTasks();
return;
}
// Ajouter les nouvelles tâches au DOM
for (const log of newLogs) {
logsSection.appendChild(this.createTaskLogCard(log));
}
// Mettre à jour le bouton de pagination
const paginationEl = document.getElementById('tasks-pagination');
if (paginationEl) {
if (this.tasksHasMore) {
const remaining = this.tasksTotalCount - this.taskLogs.length;
paginationEl.innerHTML = `
<button onclick="dashboard.loadMoreTasks()" class="px-6 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 text-sm">
<i class="fas fa-chevron-down mr-2"></i>Charger plus (${remaining} restantes)
</button>
`;
} else {
paginationEl.classList.add('hidden');
}
}
} catch (error) {
console.error('Erreur chargement logs supplémentaires:', error);
}
}
@@ -3833,12 +3927,70 @@ class DashboardManager {
this.currentCategoryFilter = 'all';
this.currentSubcategoryFilter = 'all';
this.currentStatusFilter = 'all';
this.currentSourceTypeFilter = 'all';
this.currentHourStart = '';
this.currentHourEnd = '';
this.tasksDisplayedCount = this.tasksPerPage;
// Réinitialiser les inputs d'heure
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
if (hourStartInput) hourStartInput.value = '';
if (hourEndInput) hourEndInput.value = '';
this.loadTaskLogsWithFilters();
this.showNotification('Tous les filtres effacés', 'info');
}
filterTasksBySourceType(sourceType) {
this.currentSourceTypeFilter = sourceType;
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
}
clearSourceTypeFilter() {
this.currentSourceTypeFilter = 'all';
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
this.showNotification('Filtre type effacé', 'info');
}
clearHourFilter() {
this.currentHourStart = '';
this.currentHourEnd = '';
// Réinitialiser les inputs d'heure
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
if (hourStartInput) hourStartInput.value = '';
if (hourEndInput) hourEndInput.value = '';
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
this.showNotification('Filtre horaire effacé', 'info');
}
async loadTaskLogsWithFilters() {
// Afficher un indicateur de chargement inline (pas le loader global)
const container = document.getElementById('tasks-list');
const logsSection = document.getElementById('task-logs-section');
if (logsSection) {
logsSection.innerHTML = `
<h4 class="text-sm font-semibold text-gray-400 mb-2 mt-4"><i class="fas fa-spinner fa-spin mr-2"></i>Chargement...</h4>
`;
} else if (container) {
// Garder le header mais montrer le chargement dans la liste
const existingHeader = container.querySelector('.flex.flex-col.gap-2.mb-4');
if (!existingHeader) {
container.innerHTML = `
<div class="flex items-center justify-center py-8 text-gray-400">
<i class="fas fa-spinner fa-spin mr-2"></i>
<span>Chargement...</span>
</div>
`;
}
}
const params = new URLSearchParams();
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
params.append('status', this.currentStatusFilter);
@@ -3857,16 +4009,33 @@ class DashboardManager {
if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month);
if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day);
}
// Filtres d'heure
if (this.currentHourStart) {
params.append('hour_start', this.currentHourStart);
}
if (this.currentHourEnd) {
params.append('hour_end', this.currentHourEnd);
}
if (this.currentTargetFilter && this.currentTargetFilter !== 'all') {
params.append('target', this.currentTargetFilter);
}
if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') {
params.append('category', this.currentCategoryFilter);
}
// Filtre par type de source
if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') {
params.append('source_type', this.currentSourceTypeFilter);
}
// Pagination côté serveur
params.append('limit', this.tasksPerPage);
params.append('offset', 0); // Toujours commencer à 0 lors d'un nouveau filtre
try {
const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`);
this.taskLogs = result.logs || [];
this.tasksTotalCount = result.total_count || 0;
this.tasksHasMore = result.has_more || false;
this.tasksDisplayedCount = this.taskLogs.length;
this.renderTasks();
this.updateTaskCounts();
} catch (error) {
@@ -3916,7 +4085,7 @@ class DashboardManager {
}
applyDateFilter() {
// Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l’API
// Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l'API
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
this.currentDateFilter.year = String(firstDate.getFullYear());
@@ -3926,6 +4095,12 @@ class DashboardManager {
this.currentDateFilter = { year: '', month: '', day: '' };
}
// Récupérer les heures depuis les inputs
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
this.currentHourStart = hourStartInput ? hourStartInput.value : '';
this.currentHourEnd = hourEndInput ? hourEndInput.value : '';
this.updateDateFilters();
this.loadTaskLogsWithFilters();
}
@@ -3933,32 +4108,60 @@ class DashboardManager {
clearDateFilters() {
this.currentDateFilter = { year: '', month: '', day: '' };
this.selectedTaskDates = [];
this.currentHourStart = '';
this.currentHourEnd = '';
// Réinitialiser les inputs d'heure
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
if (hourStartInput) hourStartInput.value = '';
if (hourEndInput) hourEndInput.value = '';
this.updateDateFilters();
this.renderTaskCalendar();
this.loadTaskLogsWithFilters();
}
async refreshTaskLogs() {
this.showLoading();
// Ne pas utiliser showLoading() pour éviter le message "Exécution de la tâche..."
// Afficher un indicateur de chargement inline à la place
const container = document.getElementById('tasks-list');
if (container) {
container.innerHTML = `
<div class="flex items-center justify-center py-8 text-gray-400">
<i class="fas fa-spinner fa-spin mr-2"></i>
<span>Chargement des logs...</span>
</div>
`;
}
try {
const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([
this.apiCall('/api/tasks/logs'),
this.apiCall(`/api/tasks/logs?limit=${this.tasksPerPage}&offset=0`),
this.apiCall('/api/tasks/logs/stats'),
this.apiCall('/api/tasks/logs/dates')
]);
this.taskLogs = taskLogsData.logs || [];
this.tasksTotalCount = taskLogsData.total_count || 0;
this.tasksHasMore = taskLogsData.has_more || false;
this.taskLogsStats = taskStatsData;
this.taskLogsDates = taskDatesData;
this.renderTasks();
this.updateDateFilters();
this.updateTaskCounts();
this.hideLoading();
this.showNotification('Logs de tâches rafraîchis', 'success');
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
if (container) {
container.innerHTML = `
<div class="text-center text-red-400 py-8">
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
<p>Erreur de chargement</p>
</div>
`;
}
}
}
@@ -5946,7 +6149,8 @@ class DashboardManager {
'backup': 'Backup',
'monitoring': 'Monitoring',
'system': 'System',
'general': 'Général'
'general': 'Général',
'testing': 'Testing'
};
return labels[category] || category;
}
@@ -5957,7 +6161,8 @@ class DashboardManager {
'deploy': 'fa-rocket',
'backup': 'fa-save',
'monitoring': 'fa-heartbeat',
'system': 'fa-cogs'
'system': 'fa-cogs',
'testing': 'fa-flask'
};
return icons[category] || null;
}
@@ -6872,10 +7077,20 @@ class DashboardManager {
// ===== MODAL CRÉATION/ÉDITION SCHEDULE =====
showCreateScheduleModal(prefilledPlaybook = null) {
async showCreateScheduleModal(prefilledPlaybook = null) {
this.editingScheduleId = null;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(null, prefilledPlaybook);
this.showModal('Nouveau Schedule', content, 'schedule-modal');
}
@@ -6887,6 +7102,16 @@ class DashboardManager {
this.editingScheduleId = scheduleId;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(schedule);
this.showModal(`Modifier: ${schedule.name}`, content, 'schedule-modal');
}
@@ -6921,6 +7146,8 @@ class DashboardManager {
<div class="schedule-step-dot" data-step="2">2</div>
<div class="schedule-step-connector"></div>
<div class="schedule-step-dot" data-step="3">3</div>
<div class="schedule-step-connector"></div>
<div class="schedule-step-dot" data-step="4">4</div>
</div>
<!-- Step 1: Informations de base -->
@@ -7119,6 +7346,63 @@ class DashboardManager {
</div>
</div>
<div class="flex justify-between mt-6">
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
<i class="fas fa-arrow-left mr-2"></i>Précédent
</button>
<button onclick="dashboard.scheduleModalNextStep()" class="btn-primary">
Suivant <i class="fas fa-arrow-right ml-2"></i>
</button>
</div>
</div>
<!-- Step 4: Notifications -->
<div class="schedule-modal-step" data-step="4">
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-bell text-yellow-400 mr-2"></i>Notifications</h4>
<div id="ntfy-disabled-warning" class="hidden mb-4 p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg">
<div class="flex items-center gap-2 text-yellow-400">
<i class="fas fa-exclamation-triangle"></i>
<span class="font-medium">Notifications désactivées</span>
</div>
<p class="text-sm text-yellow-300/80 mt-1">Les notifications ntfy sont actuellement désactivées dans la configuration du serveur (NTFY_ENABLED=false). Les paramètres ci-dessous seront ignorés.</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-3">Type de notification</label>
<div class="space-y-3">
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
<input type="radio" name="schedule-notification-type" value="all"
${(s.notification_type || 'all') === 'all' ? 'checked' : ''}
class="mt-1">
<div>
<span class="font-medium"><i class="fas fa-bell text-green-400 mr-2"></i>Toujours notifier</span>
<p class="text-sm text-gray-400 mt-1">Recevoir une notification à chaque exécution (succès ou échec)</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
<input type="radio" name="schedule-notification-type" value="errors"
${s.notification_type === 'errors' ? 'checked' : ''}
class="mt-1">
<div>
<span class="font-medium"><i class="fas fa-exclamation-circle text-red-400 mr-2"></i>Erreurs seulement</span>
<p class="text-sm text-gray-400 mt-1">Recevoir une notification uniquement en cas d'échec</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
<input type="radio" name="schedule-notification-type" value="none"
${s.notification_type === 'none' ? 'checked' : ''}
class="mt-1">
<div>
<span class="font-medium"><i class="fas fa-bell-slash text-gray-400 mr-2"></i>Aucune notification</span>
<p class="text-sm text-gray-400 mt-1">Ne pas envoyer de notification pour ce schedule</p>
</div>
</label>
</div>
</div>
</div>
<div class="flex justify-between mt-6">
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
<i class="fas fa-arrow-left mr-2"></i>Précédent
@@ -7131,10 +7415,27 @@ class DashboardManager {
`;
}
scheduleModalNextStep() {
if (this.scheduleModalStep < 3) {
async scheduleModalNextStep() {
if (this.scheduleModalStep < 4) {
this.scheduleModalStep++;
this.updateScheduleModalStep();
// Si on arrive à l'étape 4 (Notifications), vérifier si NTFY est activé
if (this.scheduleModalStep === 4) {
await this.checkNtfyStatus();
}
}
}
async checkNtfyStatus() {
try {
const config = await this.apiCall('/api/notifications/config');
const warningEl = document.getElementById('ntfy-disabled-warning');
if (warningEl) {
warningEl.classList.toggle('hidden', config.enabled !== false);
}
} catch (error) {
console.error('Erreur vérification statut NTFY:', error);
}
}
@@ -7221,6 +7522,7 @@ class DashboardManager {
const timeout = parseInt(document.getElementById('schedule-timeout')?.value) || 3600;
const scheduleType = document.querySelector('input[name="schedule-type"]:checked')?.value || 'recurring';
const enabled = document.getElementById('schedule-enabled')?.checked ?? true;
const notificationType = document.querySelector('input[name="schedule-notification-type"]:checked')?.value || 'all';
// Validation
if (!name || name.length < 3) {
@@ -7276,7 +7578,8 @@ class DashboardManager {
recurrence,
start_at: startAt,
enabled,
tags
tags,
notification_type: notificationType
};
try {
+10 -1
View File
@@ -78,7 +78,16 @@ if DATABASE_URL.startswith("sqlite"):
def _set_sqlite_pragmas(dbapi_connection, connection_record): # type: ignore[override]
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
# WAL mode can fail on some Docker volume mounts (e.g., NFS, CIFS, overlay issues)
# Fall back to DELETE mode if WAL fails
try:
cursor.execute("PRAGMA journal_mode=WAL")
except Exception:
# WAL not supported, use DELETE mode instead
try:
cursor.execute("PRAGMA journal_mode=DELETE")
except Exception:
pass # Ignore if this also fails
cursor.close()
async_session_maker = async_sessionmaker(
+2
View File
@@ -36,6 +36,8 @@ class Schedule(Base):
last_status: Mapped[Optional[str]] = mapped_column(String, default="never")
retry_on_failure: Mapped[Optional[int]] = mapped_column(Integer, default=0)
timeout: Mapped[Optional[int]] = mapped_column(Integer, default=3600)
# Type de notification: "none" (aucune), "all" (toujours), "errors" (erreurs seulement)
notification_type: Mapped[Optional[str]] = mapped_column(String, default="all")
run_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
success_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
failure_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
+13
View File
@@ -3,6 +3,13 @@ from .bootstrap_status import BootstrapStatusOut
from .task import TaskCreate, TaskUpdate, TaskOut
from .schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut, ScheduleRunOut
from .log import LogCreate, LogOut
from .notification import (
NtfyConfig,
NtfyAction,
NotificationRequest,
NotificationResponse,
NotificationTemplates,
)
__all__ = [
"HostCreate",
@@ -18,4 +25,10 @@ __all__ = [
"ScheduleRunOut",
"LogCreate",
"LogOut",
# Notifications
"NtfyConfig",
"NtfyAction",
"NotificationRequest",
"NotificationResponse",
"NotificationTemplates",
]
+392
View File
@@ -0,0 +1,392 @@
"""
Schémas Pydantic pour le système de notifications ntfy.
"""
from typing import Optional, List, Dict, Any, Literal
from pydantic import BaseModel, Field, field_validator
import os
class NtfyConfig(BaseModel):
"""Configuration du service de notification ntfy."""
base_url: str = Field(
default="http://localhost:8150",
description="URL de base du serveur ntfy"
)
default_topic: str = Field(
default="homelab-events",
description="Topic par défaut pour les notifications"
)
enabled: bool = Field(
default=True,
description="Activer/désactiver les notifications"
)
timeout: int = Field(
default=5,
ge=1,
le=30,
description="Timeout en secondes pour les requêtes HTTP"
)
username: Optional[str] = Field(
default=None,
description="Nom d'utilisateur pour l'authentification Basic"
)
password: Optional[str] = Field(
default=None,
description="Mot de passe pour l'authentification Basic"
)
token: Optional[str] = Field(
default=None,
description="Token Bearer pour l'authentification"
)
msg_types: List[str] = Field(
default_factory=lambda: ["ALL"],
description=(
"Types de notifications à envoyer: ALL, ERR, WARN "
"(liste séparée par des virgules, ex: 'ERR,WARN')"
),
)
@classmethod
def from_env(cls) -> "NtfyConfig":
"""Crée une configuration à partir des variables d'environnement."""
enabled_str = os.environ.get("NTFY_ENABLED", "true").lower()
enabled = enabled_str in ("true", "1", "yes", "on")
# NTFY_MSG_TYPE: ALL, ERR, WARN, ou combinaison séparée par des virgules
raw_types = os.environ.get("NTFY_MSG_TYPE", "ALL")
tokens = [t.strip().upper() for t in raw_types.split(",") if t.strip()]
# Normaliser et filtrer les valeurs invalides
valid_tokens = {"ALL", "ERR", "WARN"}
selected = [t for t in tokens if t in valid_tokens]
if not selected or "ALL" in selected:
msg_types = ["ALL"]
else:
# Supprimer les doublons en conservant l'ordre
seen = set()
msg_types = []
for t in selected:
if t not in seen:
seen.add(t)
msg_types.append(t)
return cls(
base_url=os.environ.get("NTFY_BASE_URL", "http://localhost:8150"),
default_topic=os.environ.get("NTFY_DEFAULT_TOPIC", "homelab-events"),
enabled=enabled,
timeout=int(os.environ.get("NTFY_TIMEOUT", "5")),
username=os.environ.get("NTFY_USERNAME") or None,
password=os.environ.get("NTFY_PASSWORD") or None,
token=os.environ.get("NTFY_TOKEN") or None,
msg_types=msg_types,
)
@property
def has_auth(self) -> bool:
"""Vérifie si l'authentification est configurée."""
return bool(self.token) or (bool(self.username) and bool(self.password))
@property
def allowed_levels(self) -> set[str]:
"""Retourne les niveaux logiques autorisés: INFO, WARN, ERR.
Mapping:
- ALL -> {INFO, WARN, ERR}
- ERR -> {ERR}
- WARN -> {WARN}
- ERR,WARN -> {ERR, WARN}
"""
# Si ALL est présent, tout est autorisé
if "ALL" in self.msg_types:
return {"INFO", "WARN", "ERR"}
levels: set[str] = set()
for t in self.msg_types:
if t == "ERR":
levels.add("ERR")
elif t == "WARN":
levels.add("WARN")
# Fallback: si config vide/invalide, tout autoriser
return levels or {"INFO", "WARN", "ERR"}
class NtfyAction(BaseModel):
"""Action attachée à une notification ntfy."""
action: Literal["view", "broadcast", "http"] = Field(
default="view",
description="Type d'action"
)
label: str = Field(
...,
description="Texte du bouton d'action"
)
url: Optional[str] = Field(
default=None,
description="URL pour les actions 'view' et 'http'"
)
method: Optional[str] = Field(
default=None,
description="Méthode HTTP pour l'action 'http'"
)
headers: Optional[Dict[str, str]] = Field(
default=None,
description="Headers HTTP pour l'action 'http'"
)
body: Optional[str] = Field(
default=None,
description="Corps de la requête pour l'action 'http'"
)
clear: bool = Field(
default=False,
description="Effacer la notification après l'action"
)
class NotificationRequest(BaseModel):
"""Requête pour envoyer une notification."""
topic: Optional[str] = Field(
default=None,
description="Topic cible (utilise le topic par défaut si non spécifié)"
)
message: str = Field(
...,
min_length=1,
max_length=4096,
description="Corps du message"
)
title: Optional[str] = Field(
default=None,
max_length=250,
description="Titre de la notification"
)
priority: Optional[int] = Field(
default=None,
ge=1,
le=5,
description="Priorité: 1=min, 2=low, 3=default, 4=high, 5=urgent"
)
tags: Optional[List[str]] = Field(
default=None,
description="Tags/emojis (ex: ['warning', 'skull'])"
)
click: Optional[str] = Field(
default=None,
description="URL à ouvrir au clic sur la notification"
)
attach: Optional[str] = Field(
default=None,
description="URL d'une pièce jointe"
)
actions: Optional[List[NtfyAction]] = Field(
default=None,
description="Actions attachées à la notification"
)
delay: Optional[str] = Field(
default=None,
description="Délai avant envoi (ex: '30m', '1h', '2025-01-01T10:00:00')"
)
class NotificationResponse(BaseModel):
"""Réponse après envoi d'une notification."""
success: bool = Field(
...,
description="Indique si l'envoi a réussi"
)
topic: str = Field(
...,
description="Topic utilisé"
)
message_id: Optional[str] = Field(
default=None,
description="ID du message retourné par ntfy"
)
error: Optional[str] = Field(
default=None,
description="Message d'erreur en cas d'échec"
)
# ===== Helpers pour les notifications courantes =====
class NotificationTemplates:
"""Templates de notifications prédéfinis pour les cas d'usage courants."""
@staticmethod
def app_started() -> NotificationRequest:
"""Notification de démarrage de l'application."""
return NotificationRequest(
topic=None,
title="✅ Homelab Dashboard démarré",
message="L'application Homelab Automation Dashboard est maintenant en ligne et opérationnelle.",
priority=3,
tags=["white_check_mark", "rocket"]
)
@staticmethod
def app_stopped() -> NotificationRequest:
"""Notification d'arrêt de l'application."""
return NotificationRequest(
topic=None,
title="⚠️ Homelab Dashboard arrêté",
message="L'application Homelab Automation Dashboard a été arrêtée.",
priority=4,
tags=["warning", "octagonal_sign"]
)
@staticmethod
def backup_success(
hostname: str,
duration: str,
size: Optional[str] = None
) -> NotificationRequest:
"""Notification de succès de backup."""
details = [f"• Hôte : {hostname}"]
if duration:
details.append(f"• Durée : {duration}")
if size:
details.append(f"• Taille : {size}")
return NotificationRequest(
topic=None,
title="✅ Backup terminé avec succès",
message="\n".join(details),
priority=3,
tags=["white_check_mark", "floppy_disk"]
)
@staticmethod
def backup_failed(
hostname: str,
error: str
) -> NotificationRequest:
"""Notification d'échec de backup."""
return NotificationRequest(
topic=None,
title="❌ Échec du backup",
message=f"• Hôte : {hostname}\n• Erreur : {error}",
priority=5,
tags=["x", "warning"]
)
@staticmethod
def bootstrap_started(hostname: str) -> NotificationRequest:
"""Notification de début de bootstrap."""
return NotificationRequest(
topic=None,
title="🔧 Bootstrap en cours",
message=f"Configuration initiale en cours pour l'hôte {hostname}.",
priority=3,
tags=["wrench", "computer"]
)
@staticmethod
def bootstrap_success(hostname: str) -> NotificationRequest:
"""Notification de succès de bootstrap."""
return NotificationRequest(
topic=None,
title="✅ Bootstrap terminé avec succès",
message=f"L'hôte {hostname} est maintenant configuré et prêt pour Ansible.",
priority=3,
tags=["white_check_mark", "computer"]
)
@staticmethod
def bootstrap_failed(hostname: str, error: str) -> NotificationRequest:
"""Notification d'échec de bootstrap."""
return NotificationRequest(
topic=None,
title="❌ Échec du bootstrap",
message=f"• Hôte : {hostname}\n• Erreur : {error}",
priority=5,
tags=["x", "warning"]
)
@staticmethod
def health_status_changed(
hostname: str,
new_status: Literal["up", "down"],
details: Optional[str] = None
) -> NotificationRequest:
"""Notification de changement d'état de santé."""
if new_status == "down":
return NotificationRequest(
topic=None,
title="🔴 Hôte inaccessible",
message=f"L'hôte {hostname} ne répond plus." + (f"\n• Détails : {details}" if details else ""),
priority=5,
tags=["red_circle", "warning"]
)
else:
return NotificationRequest(
topic=None,
title="🟢 Hôte de nouveau accessible",
message=f"L'hôte {hostname} est de nouveau en ligne." + (f"\n• Détails : {details}" if details else ""),
priority=3,
tags=["green_circle", "white_check_mark"]
)
@staticmethod
def task_completed(
task_name: str,
target: str,
duration: Optional[str] = None
) -> NotificationRequest:
"""Notification de tâche terminée."""
lines = [
f"• Tâche : {task_name}",
f"• Cible : {target}",
]
if duration:
lines.append(f"• Durée : {duration}")
return NotificationRequest(
topic=None,
title="✅ Tâche exécutée avec succès",
message="\n".join(lines),
priority=3,
tags=["white_check_mark", "gear"]
)
@staticmethod
def task_failed(
task_name: str,
target: str,
error: str
) -> NotificationRequest:
"""Notification d'échec de tâche."""
return NotificationRequest(
topic=None,
title="❌ Échec de la tâche",
message=f"• Tâche : {task_name}\n• Cible : {target}\n• Erreur : {error}",
priority=5,
tags=["x", "warning"]
)
@staticmethod
def schedule_executed(
schedule_name: str,
success: bool,
details: Optional[str] = None
) -> NotificationRequest:
"""Notification d'exécution de schedule."""
if success:
return NotificationRequest(
topic=None,
title="✅ Planification exécutée avec succès",
message=f"• Schedule : {schedule_name}" + (f"\n• Détails : {details}" if details else ""),
priority=3,
tags=["white_check_mark", "calendar"]
)
else:
return NotificationRequest(
topic=None,
title="❌ Échec de la planification",
message=f"• Schedule : {schedule_name}" + (f"\n• Détails : {details}" if details else ""),
priority=5,
tags=["x", "calendar"]
)
+7 -1
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from typing import List, Literal, Optional
from pydantic import BaseModel, Field, ConfigDict
# Type de notification pour les schedules
NotificationType = Literal["none", "all", "errors"]
class ScheduleBase(BaseModel):
name: str
playbook: str
@@ -18,6 +22,7 @@ class ScheduleBase(BaseModel):
cron_expression: Optional[str] = None
enabled: bool = True
tags: Optional[List[str]] = None
notification_type: NotificationType = "all"
next_run: Optional[datetime] = None
last_run: Optional[datetime] = None
@@ -38,6 +43,7 @@ class ScheduleUpdate(BaseModel):
cron_expression: Optional[str] = None
enabled: Optional[bool] = None
tags: Optional[List[str]] = None
notification_type: Optional[NotificationType] = None
next_run: Optional[datetime] = None
last_run: Optional[datetime] = None
deleted_at: Optional[datetime] = None
+10
View File
@@ -0,0 +1,10 @@
"""
Services métier pour l'API Homelab Automation.
"""
from .notification_service import NotificationService, notification_service
__all__ = [
"NotificationService",
"notification_service",
]
+523
View File
@@ -0,0 +1,523 @@
"""
Service de notification ntfy pour l'API Homelab Automation.
Ce service gère l'envoi de notifications push via ntfy de manière asynchrone,
non-bloquante et robuste (gestion d'erreurs, timeouts, authentification).
Usage:
from services.notification_service import notification_service
# Envoi simple
await notification_service.send(
topic="homelab-backup",
message="Backup terminé avec succès",
title="✅ Backup OK"
)
# Avec BackgroundTasks FastAPI
background_tasks.add_task(
notification_service.send,
topic="homelab-backup",
message="Backup terminé"
)
"""
import json
import logging
from typing import Optional, List, Dict, Any
from base64 import b64encode
import httpx
from schemas.notification import (
NtfyConfig,
NtfyAction,
NotificationRequest,
NotificationResponse,
NotificationTemplates,
)
# Logger dédié pour le service de notification
logger = logging.getLogger("homelab.notifications")
class NotificationService:
"""
Service de notification ntfy asynchrone et non-bloquant.
Caractéristiques:
- Async avec httpx.AsyncClient
- Timeout configurable
- Gestion d'erreur robuste (ne lève jamais d'exception bloquante)
- Support authentification Basic et Bearer
- Logs structurés pour debugging
"""
def __init__(self, config: Optional[NtfyConfig] = None):
"""
Initialise le service avec une configuration.
Args:
config: Configuration ntfy. Si None, charge depuis les variables d'env.
"""
self._config = config or NtfyConfig.from_env()
self._client: Optional[httpx.AsyncClient] = None
self.templates = NotificationTemplates
@property
def config(self) -> NtfyConfig:
"""Retourne la configuration actuelle."""
return self._config
@property
def enabled(self) -> bool:
"""Vérifie si les notifications sont activées."""
return self._config.enabled
def reconfigure(self, config: NtfyConfig) -> None:
"""
Reconfigure le service avec une nouvelle configuration.
Args:
config: Nouvelle configuration ntfy
"""
self._config = config
# Fermer le client existant pour forcer une reconnexion
if self._client:
# Note: on ne peut pas await ici, le client sera recréé au prochain appel
self._client = None
logger.info(f"[NTFY] Service reconfiguré: base_url={config.base_url}, enabled={config.enabled}")
async def _get_client(self) -> httpx.AsyncClient:
"""Retourne un client HTTP réutilisable."""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._config.timeout),
follow_redirects=True
)
return self._client
def _build_auth_headers(self) -> Dict[str, str]:
"""Construit les headers d'authentification si configurés."""
headers = {}
if self._config.token:
headers["Authorization"] = f"Bearer {self._config.token}"
elif self._config.username and self._config.password:
credentials = f"{self._config.username}:{self._config.password}"
encoded = b64encode(credentials.encode()).decode()
headers["Authorization"] = f"Basic {encoded}"
return headers
def _should_send(self, level: str) -> bool:
"""Détermine si une notification d'un certain niveau doit être envoyée.
Args:
level: Niveau logique de la notification: "INFO", "WARN" ou "ERR".
Returns:
True si le niveau est autorisé par la configuration NTFY_MSG_TYPE.
"""
level_up = level.upper()
allowed = self._config.allowed_levels
if level_up not in {"INFO", "WARN", "ERR"}:
return True
return level_up in allowed
def _build_json_payload(
self,
message: str,
topic: str,
title: Optional[str] = None,
priority: Optional[int] = None,
tags: Optional[List[str]] = None,
click: Optional[str] = None,
attach: Optional[str] = None,
delay: Optional[str] = None,
actions: Optional[List[NtfyAction]] = None,
) -> Dict[str, Any]:
"""
Construit le payload JSON pour ntfy.
L'envoi en JSON permet d'utiliser des caractères UTF-8 (accents, emojis)
dans le titre et les tags, contrairement aux headers HTTP qui sont limités à ASCII.
Args:
message: Corps du message
topic: Topic cible
title: Titre de la notification
priority: Priorité (1-5)
tags: Liste de tags/emojis
click: URL au clic
attach: URL pièce jointe
delay: Délai d'envoi
actions: Actions attachées
Returns:
Dictionnaire JSON à envoyer
"""
payload: Dict[str, Any] = {
"topic": topic,
"message": message,
}
if title:
payload["title"] = title
if priority is not None:
payload["priority"] = priority
if tags:
payload["tags"] = tags
if click:
payload["click"] = click
if attach:
payload["attach"] = attach
if delay:
payload["delay"] = delay
if actions:
payload["actions"] = [
{
"action": act.action,
"label": act.label,
**({
"url": act.url,
} if act.url else {}),
**({
"method": act.method,
} if act.method else {}),
**({
"clear": act.clear,
} if act.clear else {}),
}
for act in actions
]
return payload
async def send(
self,
message: str,
topic: Optional[str] = None,
title: Optional[str] = None,
priority: Optional[int] = None,
tags: Optional[List[str]] = None,
click: Optional[str] = None,
attach: Optional[str] = None,
delay: Optional[str] = None,
actions: Optional[List[NtfyAction]] = None,
) -> bool:
"""
Envoie une notification à ntfy.
Cette méthode ne lève JAMAIS d'exception - elle retourne False en cas d'erreur
et log le problème. Cela garantit qu'une notification échouée ne bloque pas
l'opération principale.
Args:
message: Corps du message (obligatoire)
topic: Topic cible (utilise default_topic si non spécifié)
title: Titre de la notification
priority: Priorité 1-5 (1=min, 3=default, 5=urgent)
tags: Liste de tags/emojis ntfy
click: URL à ouvrir au clic
attach: URL d'une pièce jointe
delay: Délai avant envoi (ex: "30m", "1h")
actions: Liste d'actions attachées
Returns:
True si l'envoi a réussi, False sinon
"""
# Vérifier si les notifications sont activées
if not self._config.enabled:
logger.debug("[NTFY] Notifications désactivées, message ignoré")
return True # On considère ça comme un "succès" car c'est intentionnel
# Utiliser le topic par défaut si non spécifié
target_topic = topic or self._config.default_topic
# Construire l'URL de base (sans le topic, car il est dans le JSON)
url = self._config.base_url.rstrip('/')
# Construire le payload JSON (supporte UTF-8 dans le titre et les tags)
payload = self._build_json_payload(
message=message,
topic=target_topic,
title=title,
priority=priority,
tags=tags,
click=click,
attach=attach,
delay=delay,
actions=actions,
)
# Headers: uniquement auth + Content-Type JSON
headers = self._build_auth_headers()
headers["Content-Type"] = "application/json"
try:
client = await self._get_client()
logger.debug(f"[NTFY] Envoi JSON vers {url}: {message[:50]}...")
response = await client.post(
url,
content=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=headers,
)
if response.status_code in (200, 201):
logger.info(f"[NTFY] ✓ Notification envoyée: topic={target_topic}, title={title or '(none)'}")
return True
else:
logger.warning(
f"[NTFY] ✗ Échec envoi: status={response.status_code}, "
f"body={response.text[:200]}"
)
return False
except httpx.TimeoutException:
logger.warning(f"[NTFY] ✗ Timeout après {self._config.timeout}s pour {url}")
return False
except httpx.ConnectError as e:
logger.warning(f"[NTFY] ✗ Connexion impossible à {url}: {e}")
return False
except Exception as e:
logger.error(f"[NTFY] ✗ Erreur inattendue: {type(e).__name__}: {e}")
return False
async def send_request(self, request: NotificationRequest) -> NotificationResponse:
"""
Envoie une notification à partir d'un objet NotificationRequest.
Args:
request: Objet NotificationRequest avec tous les paramètres
Returns:
NotificationResponse avec le résultat
"""
topic = request.topic or self._config.default_topic
success = await self.send(
message=request.message,
topic=topic,
title=request.title,
priority=request.priority,
tags=request.tags,
click=request.click,
attach=request.attach,
delay=request.delay,
actions=request.actions,
)
return NotificationResponse(
success=success,
topic=topic,
error=None if success else "Échec de l'envoi (voir logs)"
)
# ===== Méthodes helper pour les cas d'usage courants =====
async def notify_backup_success(
self,
hostname: str,
duration: Optional[str] = None,
size: Optional[str] = None
) -> bool:
"""Notification de succès de backup."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification backup_success ignorée (niveau INFO filtré)")
return True
req = self.templates.backup_success(hostname, duration, size)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_backup_failed(self, hostname: str, error: str) -> bool:
"""Notification d'échec de backup."""
if not self._should_send("ERR"):
logger.debug("[NTFY] Notification backup_failed ignorée (niveau ERR filtré)")
return True
req = self.templates.backup_failed(hostname, error)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_bootstrap_started(self, hostname: str) -> bool:
"""Notification de début de bootstrap."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification bootstrap_started ignorée (niveau INFO filtré)")
return True
req = self.templates.bootstrap_started(hostname)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_bootstrap_success(self, hostname: str) -> bool:
"""Notification de succès de bootstrap."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification bootstrap_success ignorée (niveau INFO filtré)")
return True
req = self.templates.bootstrap_success(hostname)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_bootstrap_failed(self, hostname: str, error: str) -> bool:
"""Notification d'échec de bootstrap."""
if not self._should_send("ERR"):
logger.debug("[NTFY] Notification bootstrap_failed ignorée (niveau ERR filtré)")
return True
req = self.templates.bootstrap_failed(hostname, error)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_health_changed(
self,
hostname: str,
new_status: str,
details: Optional[str] = None
) -> bool:
"""Notification de changement d'état de santé."""
status = "up" if new_status.lower() in ("up", "online", "healthy") else "down"
level = "WARN" if status == "down" else "INFO"
if not self._should_send(level):
logger.debug("[NTFY] Notification health_changed ignorée (niveau %s filtré)", level)
return True
req = self.templates.health_status_changed(hostname, status, details)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_task_completed(
self,
task_name: str,
target: str,
duration: Optional[str] = None
) -> bool:
"""Notification de tâche terminée."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification task_completed ignorée (niveau INFO filtré)")
return True
req = self.templates.task_completed(task_name, target, duration)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_task_failed(
self,
task_name: str,
target: str,
error: str
) -> bool:
"""Notification d'échec de tâche."""
if not self._should_send("ERR"):
logger.debug("[NTFY] Notification task_failed ignorée (niveau ERR filtré)")
return True
req = self.templates.task_failed(task_name, target, error)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_schedule_executed(
self,
schedule_name: str,
success: bool,
details: Optional[str] = None
) -> bool:
"""Notification d'exécution de schedule."""
level = "INFO" if success else "ERR"
if not self._should_send(level):
logger.debug("[NTFY] Notification schedule_executed ignorée (niveau %s filtré)", level)
return True
req = self.templates.schedule_executed(schedule_name, success, details)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def close(self) -> None:
"""Ferme le client HTTP proprement."""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
logger.debug("[NTFY] Client HTTP fermé")
# Instance globale du service (singleton)
# Initialisée avec la config depuis les variables d'environnement
notification_service = NotificationService()
# ===== Fonctions utilitaires pour usage direct =====
async def send_notification(
message: str,
topic: Optional[str] = None,
title: Optional[str] = None,
priority: Optional[int] = None,
tags: Optional[List[str]] = None,
) -> bool:
"""
Fonction utilitaire pour envoyer une notification rapidement.
Utilise l'instance globale du service.
Idéal pour les BackgroundTasks FastAPI.
Example:
background_tasks.add_task(
send_notification,
message="Backup terminé",
topic="homelab-backup",
title="✅ Backup OK"
)
"""
return await notification_service.send(
message=message,
topic=topic,
title=title,
priority=priority,
tags=tags,
)