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:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
Reference in New Issue
Block a user