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
628 lines
20 KiB
JavaScript
628 lines
20 KiB
JavaScript
/**
|
|
* CodeMirror 6 Editor for Ansible Playbooks
|
|
*
|
|
* Features:
|
|
* - YAML syntax highlighting
|
|
* - Line numbers
|
|
* - Current line highlighting
|
|
* - Lint gutter markers
|
|
* - Dark theme (One Dark)
|
|
*/
|
|
|
|
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
|
|
import {
|
|
EditorView,
|
|
lineNumbers,
|
|
highlightActiveLine,
|
|
highlightActiveLineGutter,
|
|
keymap,
|
|
Decoration,
|
|
gutter,
|
|
GutterMarker
|
|
} from '@codemirror/view';
|
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
|
import { yaml } from '@codemirror/lang-yaml';
|
|
import {
|
|
bracketMatching,
|
|
indentOnInput,
|
|
foldGutter,
|
|
syntaxHighlighting,
|
|
defaultHighlightStyle
|
|
} from '@codemirror/language';
|
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
|
import { oneDark } from '@codemirror/theme-one-dark';
|
|
|
|
// Effect to update lint markers
|
|
const setLintMarkers = StateEffect.define();
|
|
|
|
// Lint gutter marker class
|
|
class LintGutterMarker extends GutterMarker {
|
|
constructor(issue, index) {
|
|
super();
|
|
this.issue = issue;
|
|
this.index = index;
|
|
}
|
|
|
|
toDOM() {
|
|
const marker = document.createElement('div');
|
|
marker.className = `lint-gutter-marker lint-${this.issue.severity}`;
|
|
marker.title = `#${this.index + 1}: ${this.issue.rule_id} - ${this.issue.message}`;
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = 'lint-badge';
|
|
badge.textContent = String(this.index + 1);
|
|
|
|
if (this.issue.severity === 'error') {
|
|
badge.classList.add('error');
|
|
} else if (this.issue.severity === 'warning') {
|
|
badge.classList.add('warning');
|
|
} else {
|
|
badge.classList.add('info');
|
|
}
|
|
|
|
marker.appendChild(badge);
|
|
return marker;
|
|
}
|
|
}
|
|
|
|
// State field for lint markers
|
|
const lintMarkersField = StateField.define({
|
|
create() {
|
|
return [];
|
|
},
|
|
update(markers, tr) {
|
|
for (let e of tr.effects) {
|
|
if (e.is(setLintMarkers)) {
|
|
return e.value;
|
|
}
|
|
}
|
|
return markers;
|
|
}
|
|
});
|
|
|
|
// Lint gutter extension
|
|
const lintGutter = gutter({
|
|
class: 'cm-lint-gutter',
|
|
markers: view => {
|
|
const markers = view.state.field(lintMarkersField);
|
|
const result = [];
|
|
|
|
for (let i = 0; i < markers.length; i++) {
|
|
const issue = markers[i];
|
|
try {
|
|
const lineNum = Math.min(issue.line, view.state.doc.lines);
|
|
const line = view.state.doc.line(lineNum);
|
|
result.push(new LintGutterMarker(issue, i).range(line.from));
|
|
} catch (e) {
|
|
console.warn('Invalid line number:', issue.line);
|
|
}
|
|
}
|
|
|
|
return RangeSet.of(result, true);
|
|
},
|
|
initialSpacer: () => {
|
|
const spacer = document.createElement('div');
|
|
spacer.className = 'lint-gutter-spacer';
|
|
spacer.textContent = '99';
|
|
return spacer;
|
|
}
|
|
});
|
|
|
|
// Custom theme additions
|
|
const editorTheme = EditorView.theme({
|
|
'&': {
|
|
height: '100%',
|
|
fontSize: '14px',
|
|
backgroundColor: '#1e1e1e'
|
|
},
|
|
'.cm-scroller': {
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
|
lineHeight: '1.6'
|
|
},
|
|
'.cm-gutters': {
|
|
backgroundColor: '#252526',
|
|
borderRight: '1px solid #3c3c3c',
|
|
color: '#858585'
|
|
},
|
|
'.cm-activeLineGutter': {
|
|
backgroundColor: '#2a2d2e'
|
|
},
|
|
'.cm-activeLine': {
|
|
backgroundColor: 'rgba(139, 92, 246, 0.1)'
|
|
},
|
|
'.cm-lint-gutter': {
|
|
width: '28px',
|
|
backgroundColor: '#252526'
|
|
},
|
|
'.lint-gutter-marker': {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '100%',
|
|
height: '100%'
|
|
},
|
|
'.lint-badge': {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '18px',
|
|
height: '18px',
|
|
borderRadius: '50%',
|
|
fontSize: '10px',
|
|
fontWeight: 'bold',
|
|
color: 'white'
|
|
},
|
|
'.lint-badge.error': {
|
|
backgroundColor: '#ef4444'
|
|
},
|
|
'.lint-badge.warning': {
|
|
backgroundColor: '#f59e0b'
|
|
},
|
|
'.lint-badge.info': {
|
|
backgroundColor: '#3b82f6'
|
|
},
|
|
'.lint-gutter-spacer': {
|
|
visibility: 'hidden',
|
|
width: '28px'
|
|
},
|
|
'.cm-selectionBackground': {
|
|
backgroundColor: 'rgba(139, 92, 246, 0.3) !important'
|
|
},
|
|
'&.cm-focused .cm-selectionBackground': {
|
|
backgroundColor: 'rgba(139, 92, 246, 0.4) !important'
|
|
},
|
|
'.cm-cursor': {
|
|
borderLeftColor: '#a78bfa'
|
|
}
|
|
});
|
|
|
|
// PlaybookEditor class
|
|
class PlaybookEditor {
|
|
constructor() {
|
|
this.view = null;
|
|
this.state = {
|
|
filename: '',
|
|
originalContent: '',
|
|
isDirty: false,
|
|
isNew: false,
|
|
initialized: false,
|
|
lintStatus: 'idle',
|
|
lintResults: null,
|
|
qualityScore: null
|
|
};
|
|
}
|
|
|
|
init(containerId, content, filename, isNew = false) {
|
|
console.log('[CMEditor] Initializing for:', filename);
|
|
|
|
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;
|
|
|
|
const container = document.getElementById(containerId);
|
|
if (!container) {
|
|
console.error('[CMEditor] Container not found:', containerId);
|
|
return;
|
|
}
|
|
|
|
// Clear container
|
|
container.innerHTML = '';
|
|
|
|
// Create editor
|
|
const extensions = [
|
|
lineNumbers(),
|
|
highlightActiveLine(),
|
|
highlightActiveLineGutter(),
|
|
history(),
|
|
bracketMatching(),
|
|
indentOnInput(),
|
|
foldGutter(),
|
|
highlightSelectionMatches(),
|
|
keymap.of([
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
...searchKeymap,
|
|
indentWithTab
|
|
]),
|
|
yaml(),
|
|
oneDark,
|
|
editorTheme,
|
|
lintMarkersField,
|
|
lintGutter,
|
|
EditorView.updateListener.of(update => {
|
|
if (update.docChanged) {
|
|
this.onContentChange();
|
|
}
|
|
}),
|
|
EditorView.lineWrapping
|
|
];
|
|
|
|
const startState = EditorState.create({
|
|
doc: content,
|
|
extensions
|
|
});
|
|
|
|
this.view = new EditorView({
|
|
state: startState,
|
|
parent: container
|
|
});
|
|
|
|
this.state.initialized = true;
|
|
console.log('[CMEditor] Initialized successfully');
|
|
}
|
|
|
|
getContent() {
|
|
if (this.view) {
|
|
return this.view.state.doc.toString();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
setContent(content) {
|
|
if (this.view) {
|
|
this.view.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.view.state.doc.length,
|
|
insert: content
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
onContentChange() {
|
|
const content = this.getContent();
|
|
this.state.isDirty = content !== this.state.originalContent;
|
|
this.updateTitle();
|
|
this.validateYaml(content);
|
|
}
|
|
|
|
validateYaml(content) {
|
|
const statusEl = document.getElementById('yaml-validation-status');
|
|
if (!statusEl) return;
|
|
|
|
const errors = [];
|
|
const lines = content.split('\n');
|
|
|
|
lines.forEach((line, index) => {
|
|
if (line.includes('\t')) {
|
|
errors.push({ line: index + 1, message: 'Utilisez des espaces au lieu des tabs' });
|
|
}
|
|
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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
updateTitle() {
|
|
const titleEl = document.querySelector('.editor-title');
|
|
if (titleEl) {
|
|
const dirtyIndicator = this.state.isDirty ? ' •' : '';
|
|
titleEl.textContent = this.state.filename + dirtyIndicator;
|
|
}
|
|
}
|
|
|
|
goToLine(lineNumber) {
|
|
if (!this.view) return;
|
|
|
|
try {
|
|
const lineNum = Math.min(lineNumber, this.view.state.doc.lines);
|
|
const line = this.view.state.doc.line(lineNum);
|
|
|
|
this.view.dispatch({
|
|
selection: { anchor: line.from, head: line.to },
|
|
scrollIntoView: true
|
|
});
|
|
|
|
this.view.focus();
|
|
|
|
// Switch to editor tab
|
|
if (window.dashboard?.switchEditorTab) {
|
|
window.dashboard.switchEditorTab('editor');
|
|
}
|
|
} catch (e) {
|
|
console.error('[CMEditor] goToLine error:', e);
|
|
}
|
|
}
|
|
|
|
setLintMarkers(issues) {
|
|
if (!this.view) return;
|
|
|
|
this.view.dispatch({
|
|
effects: setLintMarkers.of(issues || [])
|
|
});
|
|
}
|
|
|
|
async runLint() {
|
|
const content = this.getContent();
|
|
if (!content.trim()) {
|
|
console.warn('[CMEditor] Cannot lint empty content');
|
|
return;
|
|
}
|
|
|
|
console.log('[CMEditor] Running lint for:', this.state.filename);
|
|
this.setLintStatus('running');
|
|
|
|
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)}/lint`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ content })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('[CMEditor] Lint result:', result);
|
|
|
|
this.state.lintResults = result;
|
|
this.state.qualityScore = result.quality_score;
|
|
|
|
// Persist last lint result (for playbooks list UI)
|
|
if (window.dashboard?.storePlaybookLintResult) {
|
|
window.dashboard.storePlaybookLintResult(this.state.filename, result);
|
|
}
|
|
|
|
// Update lint markers in gutter
|
|
this.setLintMarkers(result.issues);
|
|
|
|
// Determine status
|
|
if (result.summary.errors > 0) {
|
|
this.setLintStatus('errors');
|
|
} else if (result.summary.warnings > 0) {
|
|
this.setLintStatus('warnings');
|
|
} else {
|
|
this.setLintStatus('success');
|
|
}
|
|
|
|
// Update UI
|
|
this.updateQualityBadge();
|
|
this.updateProblemsPanel();
|
|
this.updateProblemsCount();
|
|
|
|
// Refresh playbooks list so score appears immediately
|
|
if (window.dashboard?.renderPlaybooks) {
|
|
window.dashboard.renderPlaybooks();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('[CMEditor] Lint error:', error);
|
|
this.setLintStatus('idle');
|
|
|
|
if (error.message.includes('503')) {
|
|
window.dashboard?.showNotification('ansible-lint non disponible sur le serveur', 'warning');
|
|
}
|
|
}
|
|
}
|
|
|
|
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';
|
|
|
|
btn.classList.remove('success', 'warnings', 'errors');
|
|
if (status === 'success' || status === 'warnings' || status === 'errors') {
|
|
btn.classList.add(status);
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
updateProblemsCount() {
|
|
const countEl = document.getElementById('problems-count');
|
|
if (countEl) {
|
|
const count = this.state.lintResults?.summary?.total || 0;
|
|
countEl.textContent = count;
|
|
}
|
|
}
|
|
|
|
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">';
|
|
|
|
results.issues.forEach((issue, index) => {
|
|
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>';
|
|
|
|
const badgeColor = issue.severity === 'error' ? 'bg-red-600' :
|
|
issue.severity === 'warning' ? 'bg-yellow-600' : 'bg-blue-600';
|
|
|
|
html += `
|
|
<div class="p-3 hover:bg-gray-800/50 cursor-pointer transition-colors problem-item"
|
|
onclick="window.PlaybookEditor.goToLine(${issue.line})">
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<span class="inline-flex items-center justify-center w-5 h-5 ${badgeColor} rounded-full text-xs font-bold text-white">${index + 1}</span>
|
|
${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;
|
|
}
|
|
|
|
async save() {
|
|
const content = this.getContent();
|
|
|
|
console.log('[CMEditor] 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,
|
|
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('[CMEditor] Save error:', error);
|
|
window.dashboard?.showNotification(`Erreur: ${error.message}`, 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this.view) {
|
|
this.view.destroy();
|
|
this.view = null;
|
|
}
|
|
|
|
this.state = {
|
|
filename: '',
|
|
originalContent: '',
|
|
isDirty: false,
|
|
isNew: false,
|
|
initialized: false,
|
|
lintStatus: 'idle',
|
|
lintResults: null,
|
|
qualityScore: null
|
|
};
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
const editor = new PlaybookEditor();
|
|
|
|
// Export for global use
|
|
window.PlaybookEditor = editor;
|
|
|
|
export { PlaybookEditor, editor };
|