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

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 };