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
494 lines
17 KiB
JavaScript
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;
|