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:
+773
-144
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Services métier pour l'API Homelab Automation.
|
||||
"""
|
||||
|
||||
from .notification_service import NotificationService, notification_service
|
||||
|
||||
__all__ = [
|
||||
"NotificationService",
|
||||
"notification_service",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user