homelab_automation/app/playbook_editor.js
Bruno Charest 05087aa380
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Replace manual upsert logic with SQLite native upsert in Docker CRUD repositories, enhance Ansible backup playbook with better error handling and file permissions, add favicon endpoint, and improve playbook editor UI with syntax highlighting, lint integration, quality badges, and enhanced code editing features
2025-12-17 15:36:49 -05:00

494 lines
17 KiB
JavaScript

/**
* Playbook Editor Module
*
* Éditeur de playbooks Ansible avec:
* - Intégration ansible-lint
* - Score de qualité en temps réel
* - Validation YAML basique
*/
// État global de l'éditeur
const PlaybookEditor = {
// État
state: {
filename: '',
originalContent: '',
isDirty: false,
isNew: false,
initialized: false,
// Lint
lintStatus: 'idle', // idle | running | success | warnings | errors
lintResults: null,
lintDebounceTimer: null,
lastLintTime: null,
// Qualité
qualityScore: null,
// Config
autoLint: false,
lintDelay: 2000,
},
/**
* Initialise l'éditeur (utilise le textarea existant)
*/
init(containerId, content, filename, isNew = false) {
console.log('[PlaybookEditor] Initializing for:', filename);
// Reset state
this.state.filename = filename;
this.state.originalContent = content;
this.state.isNew = isNew;
this.state.isDirty = false;
this.state.lintStatus = 'idle';
this.state.lintResults = null;
this.state.qualityScore = null;
this.state.initialized = true;
// Le textarea existe déjà dans le HTML avec id="playbook-editor-content"
const textarea = document.getElementById('playbook-editor-content');
if (!textarea) {
console.error('[PlaybookEditor] Textarea not found: playbook-editor-content');
return;
}
// Setup des listeners sur le textarea
textarea.addEventListener('input', () => this.onContentChange());
// Support Tab = 2 espaces
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
this.onContentChange();
}
});
// Validation YAML initiale
this.validateYaml(content);
console.log('[PlaybookEditor] Initialized successfully');
},
/**
* Récupère le contenu de l'éditeur
*/
getContent() {
const textarea = document.getElementById('playbook-editor-content');
if (textarea) {
return textarea.value;
}
console.warn('[PlaybookEditor] Textarea not found, returning empty');
return '';
},
/**
* Met à jour le contenu de l'éditeur
*/
setContent(content) {
const textarea = document.getElementById('playbook-editor-content');
if (textarea) {
textarea.value = content;
}
},
/**
* Appelé quand le contenu change
*/
onContentChange() {
const content = this.getContent();
this.state.isDirty = content !== this.state.originalContent;
// Mettre à jour le titre avec indicateur de modification
this.updateTitle();
// Validation YAML basique
this.validateYaml(content);
// Debounce lint auto (si activé)
if (this.state.autoLint) {
clearTimeout(this.state.lintDebounceTimer);
this.state.lintDebounceTimer = setTimeout(() => {
this.runLint();
}, this.state.lintDelay);
}
},
/**
* Validation YAML basique
*/
validateYaml(content) {
const statusEl = document.getElementById('yaml-validation-status');
if (!statusEl) return;
const errors = [];
const lines = content.split('\n');
lines.forEach((line, index) => {
// Tabs
if (line.includes('\t')) {
errors.push({ line: index + 1, message: 'Utilisez des espaces au lieu des tabs' });
}
// Indentation impaire
const leadingSpaces = (line.match(/^(\s*)/) || ['', ''])[1].length;
if (leadingSpaces % 2 !== 0 && line.trim()) {
errors.push({ line: index + 1, message: 'Indentation impaire détectée' });
}
});
if (errors.length > 0) {
statusEl.innerHTML = `
<i class="fas fa-exclamation-triangle text-yellow-400 mr-1"></i>
<span class="text-yellow-400">Ligne ${errors[0].line}: ${errors[0].message}</span>
`;
} else {
statusEl.innerHTML = `
<i class="fas fa-check-circle text-green-400 mr-1"></i>
<span class="text-gray-400">YAML valide</span>
`;
}
},
/**
* Exécute ansible-lint
*/
async runLint() {
const content = this.getContent();
if (!content.trim()) {
console.warn('[PlaybookEditor] Cannot lint empty content');
return;
}
console.log('[PlaybookEditor] Running lint for:', this.state.filename);
// Mettre à jour le statut
this.setLintStatus('running');
try {
// Récupérer le token d'authentification (accessToken est utilisé par le dashboard)
const token = localStorage.getItem('accessToken');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/playbooks/${encodeURIComponent(this.state.filename)}/lint`, {
method: 'POST',
headers: headers,
body: JSON.stringify({ content }),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[PlaybookEditor] Lint API error:', response.status, errorText);
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
console.log('[PlaybookEditor] Lint result:', result);
this.state.lintResults = result;
this.state.lastLintTime = new Date();
this.state.qualityScore = result.quality_score;
// Déterminer le statut
if (result.summary.errors > 0) {
this.setLintStatus('errors');
} else if (result.summary.warnings > 0) {
this.setLintStatus('warnings');
} else {
this.setLintStatus('success');
}
// Mettre à jour l'UI
this.updateQualityBadge();
this.updateProblemsPanel();
this.updateProblemsCount();
} catch (error) {
console.error('[PlaybookEditor] Lint error:', error);
this.setLintStatus('idle');
if (error.message.includes('503')) {
window.dashboard?.showNotification('ansible-lint non disponible sur le serveur', 'warning');
} else if (!error.message.includes('401') && !error.message.includes('403')) {
window.dashboard?.showNotification(`Erreur lint: ${error.message}`, 'error');
}
}
},
/**
* Met à jour le statut du lint
*/
setLintStatus(status) {
this.state.lintStatus = status;
const btn = document.getElementById('lint-button');
if (!btn) return;
const icons = {
idle: '<i class="fas fa-search"></i>',
running: '<i class="fas fa-spinner fa-spin"></i>',
success: '<i class="fas fa-check-circle text-green-400"></i>',
warnings: '<i class="fas fa-exclamation-triangle text-yellow-400"></i>',
errors: '<i class="fas fa-times-circle text-red-400"></i>',
};
const labels = {
idle: 'Lint',
running: 'Analyse...',
success: 'OK',
warnings: '',
errors: '',
};
const count = this.state.lintResults?.summary?.total || 0;
const label = labels[status] || '';
const countBadge = (status === 'warnings' || status === 'errors') && count > 0
? `<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full ${status === 'errors' ? 'bg-red-600' : 'bg-yellow-600'}">${count}</span>`
: '';
btn.innerHTML = `${icons[status]}${label ? ' ' + label : ''}${countBadge}`;
btn.disabled = status === 'running';
// Classes de style
btn.classList.remove('success', 'warnings', 'errors');
if (status === 'success' || status === 'warnings' || status === 'errors') {
btn.classList.add(status);
}
},
/**
* Met à jour le badge de qualité
*/
updateQualityBadge() {
const badge = document.getElementById('quality-badge');
if (!badge || this.state.qualityScore === null) return;
const score = this.state.qualityScore;
let colorClass = 'bg-green-600';
if (score < 50) colorClass = 'bg-red-600';
else if (score < 70) colorClass = 'bg-yellow-600';
else if (score < 90) colorClass = 'bg-blue-600';
badge.innerHTML = `
<span class="px-2 py-1 ${colorClass} rounded-lg text-xs font-medium text-white">
Quality: ${score}/100
</span>
`;
badge.style.display = 'block';
},
/**
* Met à jour le compteur de problèmes
*/
updateProblemsCount() {
const countEl = document.getElementById('problems-count');
const tabEl = document.getElementById('problems-tab');
if (countEl) {
const count = this.state.lintResults?.summary?.total || 0;
countEl.textContent = count;
}
if (tabEl) {
tabEl.classList.remove('errors', 'warnings');
if (this.state.lintResults?.summary?.errors > 0) {
tabEl.classList.add('errors');
} else if (this.state.lintResults?.summary?.warnings > 0) {
tabEl.classList.add('warnings');
}
}
},
/**
* Met à jour le panneau des problèmes
*/
updateProblemsPanel() {
const panel = document.getElementById('problems-panel');
if (!panel) return;
const results = this.state.lintResults;
if (!results || results.issues.length === 0) {
panel.innerHTML = `
<div class="p-4 text-center text-gray-500">
<i class="fas fa-check-circle text-green-400 text-2xl mb-2 block"></i>
<p>Aucun problème détecté</p>
<p class="text-xs mt-1">Temps: ${results?.execution_time_ms || 0}ms</p>
</div>
`;
return;
}
let html = '<div class="divide-y divide-gray-700">';
for (const issue of results.issues) {
const severityIcon = {
error: '<i class="fas fa-times-circle text-red-400"></i>',
warning: '<i class="fas fa-exclamation-triangle text-yellow-400"></i>',
info: '<i class="fas fa-info-circle text-blue-400"></i>',
}[issue.severity] || '<i class="fas fa-question-circle text-gray-400"></i>';
html += `
<div class="p-3 hover:bg-gray-800/50 cursor-pointer transition-colors problem-item"
onclick="PlaybookEditor.goToLine(${issue.line})">
<div class="flex items-start gap-3">
<div class="mt-0.5">${severityIcon}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs px-1.5 py-0.5 bg-gray-700 rounded font-mono">${this.escapeHtml(issue.rule_id)}</span>
<span class="text-xs text-gray-500">Ligne ${issue.line}</span>
</div>
<p class="text-sm text-gray-300 mt-1">${this.escapeHtml(issue.message)}</p>
${issue.fix_suggestion ? `
<p class="text-xs text-purple-400 mt-1">
<i class="fas fa-lightbulb mr-1"></i>${this.escapeHtml(issue.fix_suggestion)}
</p>
` : ''}
</div>
${issue.help_url ? `
<a href="${issue.help_url}" target="_blank" rel="noopener"
class="text-gray-500 hover:text-purple-400 transition-colors"
onclick="event.stopPropagation()">
<i class="fas fa-external-link-alt"></i>
</a>
` : ''}
</div>
</div>
`;
}
html += '</div>';
html += `<div class="p-2 text-center text-xs text-gray-500 border-t border-gray-700">Temps: ${results.execution_time_ms}ms</div>`;
panel.innerHTML = html;
},
/**
* Navigue vers une ligne dans le textarea
*/
goToLine(lineNumber) {
const textarea = document.getElementById('playbook-editor-content');
if (!textarea) return;
const lines = textarea.value.split('\n');
let pos = 0;
for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
pos += lines[i].length + 1;
}
// Sélectionner la ligne entière
const lineEnd = pos + (lines[lineNumber - 1]?.length || 0);
textarea.setSelectionRange(pos, lineEnd);
textarea.focus();
// Basculer vers l'onglet éditeur
if (window.dashboard?.switchEditorTab) {
window.dashboard.switchEditorTab('editor');
}
},
/**
* Met à jour le titre avec indicateur de modification
*/
updateTitle() {
const titleEl = document.querySelector('.editor-title');
if (titleEl) {
const dirtyIndicator = this.state.isDirty ? ' •' : '';
titleEl.textContent = this.state.filename + dirtyIndicator;
}
},
/**
* Sauvegarde le playbook
*/
async save() {
const content = this.getContent();
console.log('[PlaybookEditor] Saving, content length:', content.length);
if (!content.trim()) {
window.dashboard?.showNotification('Le contenu ne peut pas être vide', 'warning');
return false;
}
try {
const token = localStorage.getItem('accessToken');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/playbooks/${encodeURIComponent(this.state.filename)}/content`, {
method: 'PUT',
headers: headers,
body: JSON.stringify({ content }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Erreur sauvegarde');
}
this.state.originalContent = content;
this.state.isDirty = false;
this.updateTitle();
const action = this.state.isNew ? 'créé' : 'sauvegardé';
window.dashboard?.showNotification(`Playbook "${this.state.filename}" ${action}`, 'success');
return true;
} catch (error) {
console.error('[PlaybookEditor] Save error:', error);
window.dashboard?.showNotification(`Erreur: ${error.message}`, 'error');
return false;
}
},
/**
* Détruit l'éditeur et reset l'état
*/
destroy() {
clearTimeout(this.state.lintDebounceTimer);
this.state = {
filename: '',
originalContent: '',
isDirty: false,
isNew: false,
initialized: false,
lintStatus: 'idle',
lintResults: null,
lintDebounceTimer: null,
lastLintTime: null,
qualityScore: null,
autoLint: false,
lintDelay: 2000,
};
},
/**
* Échappe le HTML
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
};
// Export pour utilisation globale
window.PlaybookEditor = PlaybookEditor;