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

This commit is contained in:
2025-12-17 15:36:49 -05:00
parent 785db4b302
commit 05087aa380
77 changed files with 15286 additions and 304 deletions
File diff suppressed because one or more lines are too long
+24 -19
View File
@@ -87,23 +87,10 @@ class DockerContainerRepository:
labels: Optional[dict] = None,
compose_project: Optional[str] = None
) -> DockerContainer:
"""Create or update a container."""
existing = await self.get_by_container_id(host_id, container_id)
"""Create or update a container using SQLite upsert."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
if existing:
existing.name = name
existing.image = image
existing.state = state
existing.status = status
existing.health = health
existing.created_at = created_at
existing.ports = ports
existing.labels = labels
existing.compose_project = compose_project
existing.last_update_at = datetime.utcnow()
return existing
container = DockerContainer(
stmt = sqlite_insert(DockerContainer).values(
host_id=host_id,
container_id=container_id,
name=name,
@@ -114,10 +101,28 @@ class DockerContainerRepository:
created_at=created_at,
ports=ports,
labels=labels,
compose_project=compose_project
compose_project=compose_project,
last_update_at=datetime.utcnow()
)
self.session.add(container)
return container
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'container_id'],
set_={
'name': stmt.excluded.name,
'image': stmt.excluded.image,
'state': stmt.excluded.state,
'status': stmt.excluded.status,
'health': stmt.excluded.health,
'created_at': stmt.excluded.created_at,
'ports': stmt.excluded.ports,
'labels': stmt.excluded.labels,
'compose_project': stmt.excluded.compose_project,
'last_update_at': stmt.excluded.last_update_at
}
)
await self.session.execute(stmt)
# Return the upserted container
return await self.get_by_container_id(host_id, container_id)
async def delete_by_host(self, host_id: str) -> int:
"""Delete all containers for a host."""
+18 -13
View File
@@ -62,25 +62,30 @@ class DockerImageRepository:
size: Optional[int] = None,
created: Optional[datetime] = None
) -> DockerImage:
"""Create or update an image."""
existing = await self.get_by_image_id(host_id, image_id)
"""Create or update an image using SQLite upsert."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
if existing:
existing.repo_tags = repo_tags
existing.size = size
existing.created = created
existing.last_update_at = datetime.utcnow()
return existing
image = DockerImage(
stmt = sqlite_insert(DockerImage).values(
host_id=host_id,
image_id=image_id,
repo_tags=repo_tags,
size=size,
created=created
created=created,
last_update_at=datetime.utcnow()
)
self.session.add(image)
return image
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'image_id'],
set_={
'repo_tags': stmt.excluded.repo_tags,
'size': stmt.excluded.size,
'created': stmt.excluded.created,
'last_update_at': stmt.excluded.last_update_at
}
)
await self.session.execute(stmt)
# Return the upserted image
return await self.get_by_image_id(host_id, image_id)
async def delete_by_host(self, host_id: str) -> int:
"""Delete all images for a host."""
+18 -13
View File
@@ -55,25 +55,30 @@ class DockerVolumeRepository:
mountpoint: Optional[str] = None,
scope: Optional[str] = None
) -> DockerVolume:
"""Create or update a volume."""
existing = await self.get_by_name(host_id, name)
"""Create or update a volume using SQLite upsert."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
if existing:
existing.driver = driver
existing.mountpoint = mountpoint
existing.scope = scope
existing.last_update_at = datetime.utcnow()
return existing
volume = DockerVolume(
stmt = sqlite_insert(DockerVolume).values(
host_id=host_id,
name=name,
driver=driver,
mountpoint=mountpoint,
scope=scope
scope=scope,
last_update_at=datetime.utcnow()
)
self.session.add(volume)
return volume
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'name'],
set_={
'driver': stmt.excluded.driver,
'mountpoint': stmt.excluded.mountpoint,
'scope': stmt.excluded.scope,
'last_update_at': stmt.excluded.last_update_at
}
)
await self.session.execute(stmt)
# Return the upserted volume
return await self.get_by_name(host_id, name)
async def delete_by_host(self, host_id: str) -> int:
"""Delete all volumes for a host."""
+8
View File
@@ -9,6 +9,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse
from app.core.config import settings
from app.models.database import init_db, async_session_maker
@@ -69,6 +70,13 @@ def create_app() -> FastAPI:
</html>
"""
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
favicon_path = settings.base_dir / "static" / "favicon.ico"
if favicon_path.exists():
return FileResponse(favicon_path)
return FileResponse(settings.base_dir / "favicon.ico")
@app.get("/api", response_class=HTMLResponse)
async def api_home():
"""Page d'accueil de l'API."""
+314 -3
View File
@@ -1239,6 +1239,45 @@
background-color: rgba(156, 163, 175, 0.2);
color: #9ca3af;
}
.playbook-lint-badge {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 9999px;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(75, 85, 99, 0.35);
color: #e5e7eb;
line-height: 1;
cursor: help;
}
.playbook-lint-badge.ok {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
border-color: rgba(59, 130, 246, 0.35);
}
.playbook-lint-badge.good {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
border-color: rgba(34, 197, 94, 0.35);
}
.playbook-lint-badge.warn {
background: rgba(245, 158, 11, 0.18);
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.35);
}
.playbook-lint-badge.poor {
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.35);
}
/* Monaco Editor Modal */
.playbook-editor-modal {
@@ -1255,21 +1294,26 @@
overflow: hidden;
}
/* Simple code textarea fallback */
/* Enhanced code textarea editor */
.playbook-code-editor {
width: 100%;
height: 100%;
min-height: 400px;
background: #1e1e1e;
background: linear-gradient(to right, #252526 50px, #1e1e1e 50px);
color: #d4d4d4;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.6;
padding: 16px;
padding: 16px 16px 16px 60px;
border: none;
resize: none;
outline: none;
tab-size: 2;
-moz-tab-size: 2;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
caret-color: #a78bfa;
}
.playbook-code-editor:focus {
@@ -1277,6 +1321,37 @@
box-shadow: inset 0 0 0 2px rgba(139, 92, 246, 0.3);
}
.playbook-code-editor::selection {
background: rgba(139, 92, 246, 0.4);
}
.playbook-code-editor::-moz-selection {
background: rgba(139, 92, 246, 0.4);
}
/* Scrollbar styling for editor */
.playbook-code-editor::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.playbook-code-editor::-webkit-scrollbar-track {
background: #1e1e1e;
}
.playbook-code-editor::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 5px;
}
.playbook-code-editor::-webkit-scrollbar-thumb:hover {
background: #555;
}
.playbook-code-editor::-webkit-scrollbar-corner {
background: #1e1e1e;
}
/* Syntax highlighting hint text */
.yaml-hint {
background: rgba(139, 92, 246, 0.1);
@@ -1294,6 +1369,239 @@
padding: 12px 16px;
}
/* ===== ENHANCED PLAYBOOK EDITOR STYLES ===== */
/* Lint button states */
.lint-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
background: rgba(107, 114, 128, 0.3);
color: #d1d5db;
border: 1px solid rgba(107, 114, 128, 0.5);
}
.lint-button:hover:not(:disabled) {
background: rgba(139, 92, 246, 0.3);
border-color: rgba(139, 92, 246, 0.5);
}
.lint-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.lint-button.success {
background: rgba(16, 185, 129, 0.2);
border-color: rgba(16, 185, 129, 0.5);
color: #10b981;
}
.lint-button.warnings {
background: rgba(245, 158, 11, 0.2);
border-color: rgba(245, 158, 11, 0.5);
color: #f59e0b;
}
.lint-button.errors {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
color: #ef4444;
}
/* Quality badge */
.quality-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.quality-badge.excellent {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.quality-badge.good {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.quality-badge.warning {
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
.quality-badge.poor {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
/* Problems panel */
.problems-panel {
max-height: 200px;
overflow-y: auto;
background: rgba(17, 24, 39, 0.8);
border: 1px solid #374151;
border-radius: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(139, 92, 246, 0.5) rgba(0, 0, 0, 0.3);
}
.problems-panel::-webkit-scrollbar {
width: 6px;
}
.problems-panel::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.3);
}
.problems-panel::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.5);
border-radius: 3px;
}
.problem-item {
cursor: pointer;
transition: background 0.15s ease;
}
.problem-item:hover {
background: rgba(139, 92, 246, 0.1);
}
/* Lint gutter markers */
.cm-lint-gutter {
width: 20px;
}
.lint-gutter-marker {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 12px;
cursor: pointer;
}
.lint-gutter-marker.lint-error {
color: #ef4444;
}
.lint-gutter-marker.lint-warning {
color: #f59e0b;
}
.lint-gutter-marker.lint-info {
color: #3b82f6;
}
/* CodeMirror 6 custom styles */
.cm-editor {
height: 100%;
font-size: 14px;
}
.cm-editor .cm-scroller {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
line-height: 1.6;
}
.cm-editor .cm-gutters {
background: #1e1e1e;
border-right: 1px solid #333;
}
.cm-editor .cm-activeLineGutter {
background: rgba(139, 92, 246, 0.1);
}
.cm-editor .cm-activeLine {
background: rgba(139, 92, 246, 0.05);
}
/* Ansible syntax highlighting */
.cm-ansible-module {
color: #82aaff;
font-weight: 500;
}
.cm-ansible-key {
color: #c792ea;
}
.cm-jinja-var {
color: #c3e88d;
background: rgba(195, 232, 141, 0.1);
border-radius: 2px;
padding: 0 2px;
}
.cm-jinja-block {
color: #89ddff;
background: rgba(137, 221, 255, 0.1);
border-radius: 2px;
padding: 0 2px;
}
/* Editor tabs */
.editor-tabs {
display: flex;
gap: 2px;
background: rgba(17, 24, 39, 0.5);
padding: 4px;
border-radius: 8px;
}
.editor-tab {
padding: 6px 12px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.editor-tab:hover {
background: rgba(107, 114, 128, 0.3);
color: #e5e7eb;
}
.editor-tab.active {
background: rgba(139, 92, 246, 0.3);
color: #a78bfa;
}
.editor-tab .tab-count {
padding: 1px 6px;
border-radius: 9999px;
font-size: 0.65rem;
background: rgba(0, 0, 0, 0.3);
}
.editor-tab.errors .tab-count {
background: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.editor-tab.warnings .tab-count {
background: rgba(245, 158, 11, 0.3);
color: #fcd34d;
}
/* File size indicator */
.file-size-badge {
font-size: 0.7rem;
@@ -5043,5 +5351,8 @@
<!-- Containers Page JavaScript -->
<script src="/static/containers_page.js"></script>
<!-- CodeMirror Editor Bundle -->
<script src="/static/codemirror-editor.js"></script>
</body>
</html>
+410 -28
View File
@@ -7318,6 +7318,72 @@ class DashboardManager {
}
// ===== GESTION DES PLAYBOOKS =====
getPlaybookLintStorageKey() {
return 'playbookLintResults:v1';
}
storePlaybookLintResult(filename, lintResult) {
try {
const key = this.getPlaybookLintStorageKey();
const raw = localStorage.getItem(key);
const map = raw ? JSON.parse(raw) : {};
map[filename] = {
stored_at: new Date().toISOString(),
result: lintResult
};
localStorage.setItem(key, JSON.stringify(map));
} catch (e) {
console.warn('Failed to store lint result:', e);
}
}
getPlaybookLintResult(filename) {
try {
const raw = localStorage.getItem(this.getPlaybookLintStorageKey());
if (!raw) return null;
const map = JSON.parse(raw);
return map?.[filename] || null;
} catch (e) {
return null;
}
}
async copyPlaybookLintToClipboard(filename) {
const entry = this.getPlaybookLintResult(filename);
if (!entry?.result) {
this.showNotification('Aucun résultat lint à copier (exécutez Lint d\'abord)', 'warning');
return;
}
const payload = {
filename,
stored_at: entry.stored_at,
...entry.result
};
const text = JSON.stringify(payload, null, 2);
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
this.showNotification(`Résultat ansible-lint copié (${filename})`, 'success');
} catch (e) {
this.showNotification('Impossible de copier dans le presse-papiers', 'error');
}
}
renderPlaybooks() {
const container = document.getElementById('playbooks-list');
@@ -7417,6 +7483,25 @@ class DashboardManager {
// Calculer le temps relatif
const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue';
const lintEntry = this.getPlaybookLintResult(playbook.filename);
const lintResult = lintEntry?.result || null;
const qualityScore = typeof lintResult?.quality_score === 'number' ? lintResult.quality_score : null;
const lintSummary = lintResult?.summary || null;
const lintTooltip = lintResult ? (
`Quality: ${qualityScore}/100\n` +
`Total: ${lintSummary?.total ?? 0} | Errors: ${lintSummary?.errors ?? 0} | Warnings: ${lintSummary?.warnings ?? 0}\n` +
`Dernier lint: ${lintEntry?.stored_at ? new Date(lintEntry.stored_at).toLocaleString('fr-FR') : ''}`
) : 'Aucun lint exécuté';
let lintBadgeHtml = '';
if (qualityScore !== null) {
let cls = 'good';
if (qualityScore < 50) cls = 'poor';
else if (qualityScore < 70) cls = 'warn';
else if (qualityScore < 90) cls = 'ok';
lintBadgeHtml = `<span class="playbook-lint-badge ${cls}" title="${this.escapeHtml(lintTooltip)}">Q ${qualityScore}</span>`;
}
return `
<div class="playbook-card group" data-playbook="${this.escapeHtml(playbook.filename)}">
@@ -7426,6 +7511,7 @@ class DashboardManager {
<i class="fas fa-file-code text-purple-400"></i>
<h4 class="font-medium text-white truncate">${this.escapeHtml(playbook.filename)}</h4>
<span class="playbook-category-badge ${categoryClass}">${categoryLabel}</span>
${lintBadgeHtml}
</div>
${playbook.description ? `<p class="text-sm text-gray-400 mb-2 ml-7">${this.escapeHtml(playbook.description)}</p>` : ''}
<div class="flex items-center gap-4 text-xs text-gray-500 ml-7">
@@ -7435,6 +7521,9 @@ class DashboardManager {
</div>
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="playbook-action-btn" style="background: rgba(59, 130, 246, 0.15); color: #93c5fd;" onclick="event.stopPropagation(); dashboard.copyPlaybookLintToClipboard('${this.escapeHtml(playbook.filename)}')" title="Copier le résultat ansible-lint (JSON)">
<i class="fas fa-clipboard"></i>
</button>
<button class="playbook-action-btn edit" onclick="event.stopPropagation(); dashboard.editPlaybook('${this.escapeHtml(playbook.filename)}')" title="Modifier">
<i class="fas fa-edit"></i>
</button>
@@ -7641,38 +7730,71 @@ class DashboardManager {
const modalContent = `
<div class="space-y-4">
<!-- Barre d'outils -->
<div class="editor-toolbar flex items-center justify-between rounded-t-lg -mx-8 -mt-8 mb-4 px-6 py-3">
<!-- Barre d'outils améliorée -->
<div class="editor-toolbar flex flex-col sm:flex-row items-start sm:items-center justify-between rounded-t-lg -mx-8 -mt-8 mb-4 px-6 py-3 gap-3">
<div class="flex items-center gap-3">
<i class="fas fa-file-code text-purple-400 text-xl"></i>
<span class="font-medium text-white">${this.escapeHtml(filename)}</span>
<span class="font-medium text-white editor-title">${this.escapeHtml(filename)}</span>
${isNew ? '<span class="px-2 py-0.5 bg-green-600/30 text-green-400 text-xs rounded">Nouveau</span>' : ''}
<div id="quality-badge" style="display: none;"></div>
</div>
<div class="yaml-hint hidden sm:block">
<i class="fas fa-info-circle mr-1"></i>
Format YAML • Indentation: 2 espaces
<div class="flex items-center gap-3">
<!-- Bouton ansible-lint -->
<button id="lint-button" class="lint-button" title="Analyser avec ansible-lint">
<i class="fas fa-search"></i>
<span>Lint</span>
</button>
<div class="yaml-hint hidden sm:block">
<i class="fas fa-info-circle mr-1"></i>
Format YAML • Indentation: 2 espaces
</div>
</div>
</div>
<!-- Éditeur de code -->
<div class="playbook-editor-container">
<textarea id="playbook-editor-content"
class="playbook-code-editor"
spellcheck="false"
wrap="off">${this.escapeHtml(content)}</textarea>
<!-- Onglets Éditeur / Problèmes -->
<div class="editor-tabs mb-2">
<button class="editor-tab active" data-tab="editor" onclick="dashboard.switchEditorTab('editor')">
<i class="fas fa-code"></i>
<span>Éditeur</span>
</button>
<button class="editor-tab" data-tab="problems" id="problems-tab" onclick="dashboard.switchEditorTab('problems')">
<i class="fas fa-exclamation-circle"></i>
<span>Problèmes</span>
<span class="tab-count" id="problems-count">0</span>
</button>
</div>
<!-- Éditeur de code CodeMirror -->
<div id="editor-panel" class="playbook-editor-container">
<!-- CodeMirror sera injecté ici, fallback textarea si CM non disponible -->
</div>
<!-- Panneau des problèmes (caché par défaut) -->
<div id="problems-panel-container" class="hidden">
<div id="problems-panel" class="problems-panel">
<div class="p-4 text-center text-gray-500">
<i class="fas fa-check-circle text-green-400 text-2xl mb-2"></i>
<p>Cliquez sur "Lint" pour analyser le playbook</p>
</div>
</div>
</div>
<!-- Pied de page avec validation -->
<div class="flex items-center justify-between pt-4 border-t border-gray-700">
<div id="yaml-validation-status" class="text-sm">
<i class="fas fa-check-circle text-green-400 mr-1"></i>
<span class="text-gray-400">YAML valide</span>
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between pt-4 border-t border-gray-700 gap-3">
<div class="flex items-center gap-4">
<div id="yaml-validation-status" class="text-sm flex items-center">
<i class="fas fa-check-circle text-green-400 mr-1"></i>
<span class="text-gray-400">YAML valide</span>
</div>
<div id="lint-status" class="text-sm text-gray-500 hidden">
<span id="lint-execution-time"></span>
</div>
</div>
<div class="flex gap-3">
<button onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors">
<i class="fas fa-times mr-2"></i>Annuler
</button>
<button onclick="dashboard.savePlaybook('${this.escapeHtml(filename)}', ${isNew})"
<button onclick="dashboard.savePlaybookEnhanced('${this.escapeHtml(filename)}', ${isNew})"
class="px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors shadow-lg shadow-purple-500/20">
<i class="fas fa-save mr-2"></i>Sauvegarder
</button>
@@ -7683,23 +7805,283 @@ class DashboardManager {
this.showModal(title, modalContent);
// Setup de la validation YAML basique + support de la touche Tab
// Ajouter classe pour modal large
setTimeout(() => {
const textarea = document.getElementById('playbook-editor-content');
if (textarea) {
textarea.addEventListener('input', () => this.validateYamlContent(textarea.value));
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;
const modalCard = document.querySelector('#modal .glass-card');
if (modalCard) {
modalCard.classList.add('playbook-editor-modal');
}
}, 10);
// Initialiser l'éditeur CodeMirror
setTimeout(() => {
if (window.PlaybookEditor && window.PlaybookEditor.init) {
// Utiliser CodeMirror avec lint
console.log('[Dashboard] Initializing CodeMirror editor');
window.PlaybookEditor.init('editor-panel', content, filename, isNew);
} else {
// Fallback: créer un textarea basique
console.log('[Dashboard] CodeMirror not available, using textarea fallback');
const container = document.getElementById('editor-panel');
if (container) {
container.innerHTML = `<textarea id="playbook-editor-content"
class="playbook-code-editor"
spellcheck="false"
wrap="off">${this.escapeHtml(content)}</textarea>`;
const textarea = document.getElementById('playbook-editor-content');
if (textarea) {
textarea.addEventListener('input', () => this.validateYamlContent(textarea.value));
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;
}
});
}
}
}
// Setup du bouton lint - appeler directement PlaybookEditor si initialisé
const lintBtn = document.getElementById('lint-button');
if (lintBtn) {
lintBtn.addEventListener('click', async () => {
if (window.PlaybookEditor?.state?.initialized) {
await window.PlaybookEditor.runLint();
} else {
await this.runAnsibleLint(filename);
}
});
}
}, 100);
}
switchEditorTab(tabName) {
const tabs = document.querySelectorAll('.editor-tab');
tabs.forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
const editorPanel = document.getElementById('editor-panel');
const problemsPanel = document.getElementById('problems-panel-container');
if (tabName === 'editor') {
editorPanel?.classList.remove('hidden');
problemsPanel?.classList.add('hidden');
} else {
editorPanel?.classList.add('hidden');
problemsPanel?.classList.remove('hidden');
}
}
async runAnsibleLint(filename) {
// Utiliser PlaybookEditor si initialisé
if (window.PlaybookEditor?.state?.initialized) {
console.log('[Dashboard] Using PlaybookEditor.runLint()');
await window.PlaybookEditor.runLint();
return;
}
// Fallback: récupérer le contenu du textarea
const textarea = document.getElementById('playbook-editor-content');
const content = textarea?.value || '';
if (!content.trim()) {
this.showNotification('Le contenu est vide', 'warning');
return;
}
console.log('[Dashboard] Fallback lint, content length:', content.length);
// Fallback: appel API direct
const lintBtn = document.getElementById('lint-button');
if (lintBtn) {
lintBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Analyse...';
lintBtn.disabled = true;
}
try {
const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/lint`, {
method: 'POST',
body: JSON.stringify({ content })
});
// Mettre à jour l'UI
this.updateLintResults(result);
} catch (error) {
console.error('Lint error:', error);
if (lintBtn) {
lintBtn.innerHTML = '<i class="fas fa-search"></i> Lint';
lintBtn.disabled = false;
}
if (!error.message.includes('503')) {
this.showNotification(`Erreur lint: ${error.message}`, 'error');
}
}
}
updateLintResults(result) {
const lintBtn = document.getElementById('lint-button');
const problemsCount = document.getElementById('problems-count');
const problemsTab = document.getElementById('problems-tab');
const problemsPanel = document.getElementById('problems-panel');
const qualityBadge = document.getElementById('quality-badge');
const { summary, quality_score, issues, execution_time_ms } = result;
// Persist last lint for this playbook (so playbooks list can show score)
try {
const titleEl = document.querySelector('.editor-title');
const filename = titleEl?.textContent?.replace(/\s*•\s*$/, '') || null;
if (filename) {
this.storePlaybookLintResult(filename, result);
// Refresh playbooks list immediately
this.renderPlaybooks();
}
} catch (e) {
// no-op
}
// Mettre à jour le bouton
if (lintBtn) {
lintBtn.disabled = false;
if (summary.errors > 0) {
lintBtn.innerHTML = `<i class="fas fa-times-circle text-red-400"></i><span class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-red-600">${summary.total}</span>`;
lintBtn.className = 'lint-button errors';
} else if (summary.warnings > 0) {
lintBtn.innerHTML = `<i class="fas fa-exclamation-triangle text-yellow-400"></i><span class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-yellow-600">${summary.total}</span>`;
lintBtn.className = 'lint-button warnings';
} else {
lintBtn.innerHTML = '<i class="fas fa-check-circle text-green-400"></i> OK';
lintBtn.className = 'lint-button success';
}
}
// Mettre à jour le compteur de problèmes
if (problemsCount) {
problemsCount.textContent = summary.total;
}
// Mettre à jour l'onglet problèmes
if (problemsTab) {
problemsTab.classList.remove('errors', 'warnings');
if (summary.errors > 0) {
problemsTab.classList.add('errors');
} else if (summary.warnings > 0) {
problemsTab.classList.add('warnings');
}
}
// Mettre à jour le badge de qualité
if (qualityBadge) {
let colorClass = 'excellent';
if (quality_score < 50) colorClass = 'poor';
else if (quality_score < 70) colorClass = 'warning';
else if (quality_score < 90) colorClass = 'good';
qualityBadge.innerHTML = `<span class="quality-badge ${colorClass}">Quality: ${quality_score}/100</span>`;
qualityBadge.style.display = 'block';
}
// Mettre à jour le panneau des problèmes
if (problemsPanel) {
if (issues.length === 0) {
problemsPanel.innerHTML = `
<div class="p-4 text-center text-gray-500">
<i class="fas fa-check-circle text-green-400 text-2xl mb-2"></i>
<p>Aucun problème détecté</p>
<p class="text-xs mt-1">Temps d'exécution: ${execution_time_ms}ms</p>
</div>
`;
} else {
let html = '<div class="divide-y divide-gray-700">';
for (const issue of 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="dashboard.goToEditorLine(${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 d'exécution: ${execution_time_ms}ms</div>`;
problemsPanel.innerHTML = html;
}
}
}
goToEditorLine(lineNumber) {
// Utiliser PlaybookEditor si disponible
if (window.PlaybookEditor?.goToLine) {
window.PlaybookEditor.goToLine(lineNumber);
this.switchEditorTab('editor');
return;
}
// Fallback textarea
const textarea = document.getElementById('playbook-editor-content');
if (textarea) {
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;
}
textarea.setSelectionRange(pos, pos + (lines[lineNumber - 1]?.length || 0));
textarea.focus();
this.switchEditorTab('editor');
}
}
async savePlaybookEnhanced(filename, isNew = false) {
// Utiliser PlaybookEditor si initialisé
if (window.PlaybookEditor?.state?.initialized) {
console.log('[Dashboard] Using PlaybookEditor.save()');
const success = await window.PlaybookEditor.save();
if (success) {
this.closeModal();
await this.refreshPlaybooks();
}
return;
}
// Fallback vers la méthode originale
console.log('[Dashboard] Fallback to savePlaybook()');
await this.savePlaybook(filename, isNew);
}
validateYamlContent(content) {
const statusEl = document.getElementById('yaml-validation-status');
+493
View File
@@ -0,0 +1,493 @@
/**
* 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;
+2
View File
@@ -25,6 +25,7 @@ from app.routes.builtin_playbooks import router as builtin_playbooks_router
from app.routes.server import router as server_router
from app.routes.alerts import router as alerts_router
from app.routes.docker import router as docker_router
from app.routes.lint import router as lint_router
# Router principal qui agrège tous les sous-routers
api_router = APIRouter()
@@ -48,6 +49,7 @@ api_router.include_router(builtin_playbooks_router, prefix="/builtin-playbooks",
api_router.include_router(server_router, prefix="/server", tags=["Server"])
api_router.include_router(alerts_router, prefix="/alerts", tags=["Alerts"])
api_router.include_router(docker_router, prefix="/docker", tags=["Docker"])
api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
__all__ = [
"api_router",
+37 -1
View File
@@ -7,6 +7,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import OperationalError
@@ -24,6 +25,7 @@ from app.services.builtin_playbooks import (
)
router = APIRouter()
logger = logging.getLogger("homelab.metrics")
async def _sync_hosts_from_inventory(db_session: AsyncSession) -> int:
@@ -202,11 +204,40 @@ async def collect_all_metrics(
hosts_bg = await host_repo_bg.list(limit=1000)
inventory_hosts = ansible_service.get_hosts_from_inventory()
inventory_names = {h.name for h in (inventory_hosts or []) if getattr(h, "name", None)}
skipped = 0
if inventory_names:
filtered = [h for h in hosts_bg if h.name in inventory_names]
skipped = len(hosts_bg) - len(filtered)
hosts_bg = filtered
for host in hosts_bg:
try:
result = await service.execute_builtin("collect_system_info", host.name)
ok = bool(result.get("success", False))
stderr = (result.get("stderr") or "").strip()
stdout = (result.get("stdout") or "").strip()
return_code = result.get("return_code")
error_detail = None
if not ok:
# Keep payload small but actionable
if stderr:
error_detail = stderr[:400]
elif stdout:
error_detail = stdout[:400]
else:
error_detail = result.get("error") or "Unknown error"
logger.warning(
"[METRICS] Collection failed for host=%s return_code=%s error=%s",
host.name,
return_code,
error_detail,
)
if ok:
for hostname, metrics_data in result.get("parsed_metrics", {}).items():
target_host = await host_repo_bg.get_by_name(hostname)
@@ -226,9 +257,12 @@ async def collect_all_metrics(
results.append({
"host": host.name,
"success": ok,
"return_code": return_code,
"error": error_detail,
})
except Exception as e:
await session.rollback()
logger.exception("[METRICS] Unhandled exception during collection for host=%s", host.name)
results.append({
"host": host.name,
"success": False,
@@ -240,7 +274,9 @@ async def collect_all_metrics(
"data": {
"total": len(results),
"success": sum(1 for r in results if r.get("success")),
"failed": sum(1 for r in results if not r.get("success"))
"failed": sum(1 for r in results if not r.get("success")),
"skipped": skipped,
"results": results,
}
})
+449
View File
@@ -0,0 +1,449 @@
"""
Routes API pour l'intégration ansible-lint.
"""
import asyncio
import json
import logging
import re
import tempfile
import time
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from app.core.dependencies import verify_api_key
from app.schemas.lint import (
LintRequest,
LintResponse,
LintIssue,
LintSummary,
)
router = APIRouter()
logger = logging.getLogger("homelab.lint")
# Mapping des tags ansible-lint vers nos sévérités
SEVERITY_MAP = {
"blocker": "error",
"critical": "error",
"major": "error",
"minor": "warning",
"info": "info",
# Fallback par type de règle
"error": "error",
"warning": "warning",
}
# Suggestions de fix pour certaines règles courantes
FIX_SUGGESTIONS = {
"risky-file-permissions": "Ajouter 'mode: \"0644\"' ou le mode approprié",
"no-changed-when": "Ajouter 'changed_when: false' ou une condition appropriée",
"name[missing]": "Ajouter un 'name:' descriptif à cette tâche",
"yaml[truthy]": "Remplacer 'yes/no' par 'true/false'",
"fqcn[action-core]": "Utiliser le nom complet du module (ex: ansible.builtin.copy)",
"fqcn[action]": "Utiliser le nom complet du module avec sa collection",
"no-free-form": "Utiliser la syntaxe structurée au lieu de la forme libre",
"command-instead-of-shell": "Utiliser ansible.builtin.command au lieu de shell",
"package-latest": "Spécifier une version au lieu de 'state: latest'",
"no-handler": "Ajouter un handler avec notify pour cette tâche",
"literal-compare": "Utiliser 'when: var' au lieu de 'when: var == true'",
"empty-string-compare": "Utiliser 'when: var | length > 0' au lieu de comparaison vide",
"risky-shell-pipe": "Ajouter 'set -o pipefail' ou gérer les erreurs de pipe",
"no-jinja-when": "Retirer les accolades Jinja dans la clause when",
}
# URLs de documentation pour les règles
HELP_URLS = {
"risky-file-permissions": "https://ansible.readthedocs.io/projects/lint/rules/risky-file-permissions/",
"no-changed-when": "https://ansible.readthedocs.io/projects/lint/rules/no-changed-when/",
"name": "https://ansible.readthedocs.io/projects/lint/rules/name/",
"yaml": "https://ansible.readthedocs.io/projects/lint/rules/yaml/",
"fqcn": "https://ansible.readthedocs.io/projects/lint/rules/fqcn/",
}
def calculate_quality_score(issues: list[LintIssue], line_count: int) -> int:
"""
Calcule un score de qualité 0-100 basé sur les problèmes détectés.
Formule:
- Base: 100 points
- Erreur: -15 points
- Warning: -5 points
- Info: -1 point
- Bonus: +5 si aucune erreur, +3 si aucun warning
- Minimum: 0, Maximum: 100
"""
score = 100
errors = sum(1 for i in issues if i.severity == "error")
warnings = sum(1 for i in issues if i.severity == "warning")
infos = sum(1 for i in issues if i.severity == "info")
score -= errors * 15
score -= warnings * 5
score -= infos * 1
# Bonus pour code propre
if errors == 0:
score = min(100, score + 5)
if warnings == 0:
score = min(100, score + 3)
# Normalisation relative à la taille du fichier (playbooks plus longs tolèrent plus d'issues)
if line_count > 50:
tolerance_factor = min(1.2, 1 + (line_count - 50) / 200)
score = int(score * tolerance_factor)
return max(0, min(100, score))
def get_severity_from_tag(tag: str) -> str:
"""Détermine la sévérité à partir du tag ansible-lint."""
tag_lower = tag.lower()
return SEVERITY_MAP.get(tag_lower, "warning")
def get_help_url(rule_id: str) -> Optional[str]:
"""Retourne l'URL de documentation pour une règle."""
# Essayer le rule_id complet d'abord
if rule_id in HELP_URLS:
return HELP_URLS[rule_id]
# Essayer la partie principale (avant les crochets)
base_rule = rule_id.split("[")[0]
if base_rule in HELP_URLS:
return HELP_URLS[base_rule]
# URL générique ansible-lint
return f"https://ansible.readthedocs.io/projects/lint/rules/{base_rule}/"
def get_fix_suggestion(rule_id: str) -> Optional[str]:
"""Retourne une suggestion de fix pour une règle."""
if rule_id in FIX_SUGGESTIONS:
return FIX_SUGGESTIONS[rule_id]
# Essayer la partie principale
base_rule = rule_id.split("[")[0]
return FIX_SUGGESTIONS.get(base_rule)
def parse_ansible_lint_json(stdout: str, original_filename: str) -> list[LintIssue]:
"""
Parse la sortie JSON de ansible-lint.
Format attendu (ansible-lint >= 6.0):
[
{
"type": "issue",
"check_name": "name[missing]",
"categories": ["idiom"],
"severity": "minor",
"description": "All tasks should be named.",
"fingerprint": "...",
"location": {
"path": "playbook.yml",
"lines": {"begin": 10}
}
}
]
"""
issues = []
if not stdout.strip():
return issues
try:
# ansible-lint peut retourner une liste ou un objet
data = json.loads(stdout)
if isinstance(data, dict):
# Nouveau format avec clé "results" ou autre
items = data.get("results", data.get("issues", []))
elif isinstance(data, list):
items = data
else:
logger.warning("Format ansible-lint inattendu: %s", type(data))
return issues
for item in items:
if not isinstance(item, dict):
continue
# Extraire les informations
rule_id = item.get("check_name") or item.get("rule", {}).get("id", "unknown")
# Déterminer la sévérité
severity_raw = item.get("severity", "minor")
severity = get_severity_from_tag(severity_raw)
# Message
message = (
item.get("description") or
item.get("message") or
item.get("rule", {}).get("description", "Issue detected")
)
# Location
location = item.get("location", {})
lines = location.get("lines", {})
line = lines.get("begin", 1) if isinstance(lines, dict) else 1
# Fallback pour anciens formats
if line == 1 and "linenumber" in item:
line = item["linenumber"]
column = location.get("positions", {}).get("begin", {}).get("column", 1)
if column == 1 and "column" in item:
column = item["column"]
# Contexte
context = item.get("content", {}).get("body") if isinstance(item.get("content"), dict) else None
issue = LintIssue(
rule_id=rule_id,
severity=severity,
message=message,
line=line,
column=column,
context=context,
help_url=get_help_url(rule_id),
fix_suggestion=get_fix_suggestion(rule_id),
)
issues.append(issue)
except json.JSONDecodeError as e:
logger.warning("Impossible de parser JSON ansible-lint: %s", e)
# Fallback: essayer de parser le format texte
issues = parse_ansible_lint_text(stdout, original_filename)
return issues
def parse_ansible_lint_text(stdout: str, original_filename: str) -> list[LintIssue]:
"""
Parse la sortie texte de ansible-lint (fallback).
Format typique:
playbook.yml:10: name[missing]: All tasks should be named.
"""
issues = []
# Pattern: filename:line: rule: message
# ou: filename:line:col: rule: message
pattern = re.compile(
r'^(?P<file>[^:]+):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?P<rule>[^:]+):\s*(?P<message>.+)$'
)
for line in stdout.strip().split('\n'):
line = line.strip()
if not line:
continue
match = pattern.match(line)
if match:
rule_id = match.group('rule').strip()
# Déterminer sévérité basée sur le nom de la règle
severity = "warning"
if any(x in rule_id.lower() for x in ['error', 'fatal', 'risky']):
severity = "error"
elif any(x in rule_id.lower() for x in ['info', 'hint']):
severity = "info"
issue = LintIssue(
rule_id=rule_id,
severity=severity,
message=match.group('message').strip(),
line=int(match.group('line')),
column=int(match.group('col') or 1),
help_url=get_help_url(rule_id),
fix_suggestion=get_fix_suggestion(rule_id),
)
issues.append(issue)
return issues
@router.post("/{filename}/lint", response_model=LintResponse)
async def lint_playbook(
filename: str,
request: LintRequest,
api_key_valid: bool = Depends(verify_api_key)
):
"""
Exécute ansible-lint sur le contenu d'un playbook.
Retourne les problèmes détectés avec leur sévérité, position,
et suggestions de correction.
"""
start_time = time.time()
# Valider le filename
if not filename.endswith(('.yml', '.yaml')):
raise HTTPException(status_code=400, detail="Extension invalide")
# Créer fichier temporaire pour ansible-lint
temp_file = None
try:
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.yml',
delete=False,
encoding='utf-8'
) as f:
f.write(request.content)
temp_file = Path(f.name)
# Construire la commande ansible-lint
cmd = [
'ansible-lint',
'--format', 'json',
'--nocolor',
'--offline', # Ne pas télécharger de rôles Galaxy
'-q', # Quiet mode
]
# Ajouter les règles à ignorer
if request.skip_rules:
skip_list = ','.join(request.skip_rules)
cmd.extend(['--skip-list', skip_list])
# Fichier à analyser
cmd.append(str(temp_file))
logger.debug("Executing ansible-lint: %s", ' '.join(cmd))
# Exécuter ansible-lint
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=temp_file.parent
)
# Timeout de 30 secondes
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=30.0
)
except asyncio.TimeoutError:
process.kill()
raise HTTPException(
status_code=504,
detail="ansible-lint timeout (>30s)"
)
stdout_str = stdout.decode('utf-8', errors='replace')
stderr_str = stderr.decode('utf-8', errors='replace')
logger.debug("ansible-lint return code: %d", process.returncode)
logger.debug("ansible-lint stdout: %s", stdout_str[:500] if stdout_str else "(empty)")
# Parser les résultats
# Note: ansible-lint retourne un code non-zero s'il trouve des problèmes
# ce qui est normal et ne doit pas être traité comme une erreur
issues = parse_ansible_lint_json(stdout_str, filename)
# Si pas de JSON, essayer stderr (certaines versions)
if not issues and stderr_str:
issues = parse_ansible_lint_text(stderr_str, filename)
except FileNotFoundError:
raise HTTPException(
status_code=503,
detail="ansible-lint non disponible sur ce serveur"
)
except HTTPException:
# Re-raise HTTP exceptions (like timeout 504)
raise
except Exception as e:
logger.exception("Erreur exécution ansible-lint")
raise HTTPException(
status_code=500,
detail=f"Erreur ansible-lint: {str(e)}"
)
# Calculer le score de qualité
line_count = len(request.content.split('\n'))
quality_score = calculate_quality_score(issues, line_count)
# Calculer le temps d'exécution
execution_time_ms = int((time.time() - start_time) * 1000)
# Construire la réponse
summary = LintSummary(
total=len(issues),
errors=sum(1 for i in issues if i.severity == "error"),
warnings=sum(1 for i in issues if i.severity == "warning"),
info=sum(1 for i in issues if i.severity == "info"),
)
return LintResponse(
success=True,
execution_time_ms=execution_time_ms,
summary=summary,
quality_score=quality_score,
issues=issues,
)
finally:
# Nettoyer le fichier temporaire
if temp_file and temp_file.exists():
try:
temp_file.unlink()
except Exception:
pass
@router.get("/rules")
async def list_lint_rules(api_key_valid: bool = Depends(verify_api_key)):
"""
Liste les règles ansible-lint disponibles.
Utile pour permettre à l'utilisateur de configurer les règles à ignorer.
"""
try:
process = await asyncio.create_subprocess_exec(
'ansible-lint',
'--list-rules',
'--format', 'json',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
process.communicate(),
timeout=10.0
)
stdout_str = stdout.decode('utf-8', errors='replace')
try:
rules = json.loads(stdout_str)
return {"rules": rules}
except json.JSONDecodeError:
# Retourner les règles courantes connues
return {
"rules": [
{"id": "yaml", "description": "YAML syntax and formatting"},
{"id": "name", "description": "Task and play naming"},
{"id": "fqcn", "description": "Fully Qualified Collection Names"},
{"id": "risky-file-permissions", "description": "File permissions"},
{"id": "no-changed-when", "description": "Changed when conditions"},
{"id": "command-instead-of-shell", "description": "Command vs shell"},
{"id": "no-free-form", "description": "Module syntax"},
]
}
except Exception as e:
logger.warning("Impossible de lister les règles: %s", e)
raise HTTPException(
status_code=503,
detail="Impossible de récupérer la liste des règles"
)
+51
View File
@@ -0,0 +1,51 @@
"""
Schémas Pydantic pour l'API ansible-lint.
"""
from typing import Optional
from pydantic import BaseModel, Field
class LintRequest(BaseModel):
"""Requête de lint pour un playbook."""
content: str = Field(..., description="Contenu YAML du playbook")
rules: Optional[list[str]] = Field(None, description="Règles à activer (toutes par défaut)")
skip_rules: Optional[list[str]] = Field(None, description="Règles à ignorer")
class LintIssue(BaseModel):
"""Un problème détecté par ansible-lint."""
rule_id: str = Field(..., description="Identifiant de la règle")
severity: str = Field(..., description="Sévérité: error, warning, info")
message: str = Field(..., description="Message descriptif")
line: int = Field(..., description="Numéro de ligne (1-indexed)")
column: int = Field(1, description="Numéro de colonne (1-indexed)")
context: Optional[str] = Field(None, description="Extrait de code concerné")
help_url: Optional[str] = Field(None, description="URL de documentation")
fix_suggestion: Optional[str] = Field(None, description="Suggestion de correction")
class LintSummary(BaseModel):
"""Résumé des résultats de lint."""
total: int = Field(..., description="Nombre total de problèmes")
errors: int = Field(..., description="Nombre d'erreurs")
warnings: int = Field(..., description="Nombre d'avertissements")
info: int = Field(..., description="Nombre d'infos")
class LintResponse(BaseModel):
"""Réponse de l'API ansible-lint."""
success: bool = Field(..., description="True si le lint a été exécuté")
execution_time_ms: int = Field(..., description="Temps d'exécution en ms")
summary: LintSummary = Field(..., description="Résumé des problèmes")
quality_score: int = Field(..., description="Score de qualité 0-100")
issues: list[LintIssue] = Field(default_factory=list, description="Liste des problèmes")
class LintHistoryEntry(BaseModel):
"""Entrée historique de lint pour un playbook."""
timestamp: str = Field(..., description="Date/heure ISO")
quality_score: int = Field(..., description="Score de qualité")
errors: int = Field(..., description="Nombre d'erreurs")
warnings: int = Field(..., description="Nombre d'avertissements")
info: int = Field(..., description="Nombre d'infos")
+17 -4
View File
@@ -152,6 +152,17 @@ class SchedulerService:
else:
return CronTrigger(hour=hour, minute=minute, timezone=tz)
def _get_job_next_run_time(self, job: Any) -> Optional[datetime]:
"""Retourne la prochaine exécution d'un job APScheduler (compat APScheduler 3/4)."""
if job is None:
return None
# APScheduler 3.x
next_run = getattr(job, "next_run_time", None)
if next_run is not None:
return next_run
# APScheduler 4.x
return getattr(job, "next_fire_time", None)
def _add_job_for_schedule(self, schedule: Schedule):
"""Ajoute un job APScheduler pour un schedule."""
job_id = f"schedule_{schedule.id}"
@@ -184,8 +195,9 @@ class SchedulerService:
# Mettre à jour next_run_at
job = self.scheduler.get_job(job_id)
if job and job.next_run_time:
schedule.next_run_at = job.next_run_time
next_run = self._get_job_next_run_time(job)
if next_run:
schedule.next_run_at = next_run
self._schedules_cache[schedule.id] = schedule
async def _execute_schedule(self, schedule_id: str):
@@ -363,8 +375,9 @@ class SchedulerService:
# Mettre à jour next_run
job = self.scheduler.get_job(f"schedule_{schedule_id}")
if job and job.next_run_time:
schedule.next_run_at = job.next_run_time
next_run = self._get_job_next_run_time(job)
if next_run:
schedule.next_run_at = next_run
# Notifier via WebSocket
await ws_manager.broadcast({
File diff suppressed because one or more lines are too long