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
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
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
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user