Skip to content

Commit 49eb377

Browse files
ismoilovdevmlclaude
andcommitted
[ENHANCEMENT] Add enterprise features to Ansible Dashboard v2
Major Features Added: - 🎨 Monaco Editor for YAML/INI syntax highlighting - 🌈 ANSI color support for terminal output (improved visibility) - πŸ“š History page with persistent storage (100 executions) - ⏱️ Execution time tracking and duration display - πŸ“Š Statistics dashboard with analytics - πŸ“₯ Export logs (TXT/JSON) and clipboard copy - ⭐ Favorites system with local storage - πŸ”” Desktop notifications on completion/failure - 🎯 Multi-page navigation (Dashboard/History/Statistics) UI/UX Improvements: - Default text color: #e5e7eb (light gray) for better visibility - ANSI colors: Red errors, Yellow warnings, Green success - Background: #111827 (dark) for better contrast - Monaco Editor with line numbers and auto-formatting - Progress bars in statistics - Time ago formatting with date-fns - Responsive grid layout Backend v2 Updates: - History persistence to /tmp/ansible_dashboard_history.json - Statistics calculations (success rate, avg duration) - Most used folders tracking - Recent activity (24h) filtering - ANSI color preservation in output New Dependencies: - @monaco-editor/react@^4.6.0 - ansi-to-html@^0.7.2 - date-fns@^3.0.0 - @types/node@^20.10.0 Tested: - βœ… YAML editing with syntax highlighting - βœ… Colored output visibility - βœ… History storage and retrieval - βœ… Statistics calculations - βœ… Export functionality - βœ… Favorites persistence - βœ… All 3 pages navigation πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent c3a3147 commit 49eb377

File tree

3 files changed

+902
-217
lines changed

3 files changed

+902
-217
lines changed

β€Ždashboard/backend/app.pyβ€Ž

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
from pathlib import Path
1111
import uuid
1212
from datetime import datetime
13+
import json
14+
import time
1315

14-
app = FastAPI(title="Ansible Dashboard")
16+
app = FastAPI(title="Ansible Dashboard v2")
1517

1618
app.add_middleware(
1719
CORSMiddleware,
@@ -24,13 +26,37 @@
2426
# Store for running jobs
2527
jobs_store: Dict[str, Dict[str, Any]] = {}
2628

29+
# History store (persistent)
30+
HISTORY_FILE = Path("/tmp/ansible_dashboard_history.json")
31+
history_store: List[Dict[str, Any]] = []
32+
2733
# Base paths
28-
# Check if running in Docker (Ansible folder is mounted at /app/Ansible)
2934
if Path("/app/Ansible").exists():
3035
ANSIBLE_BASE = Path("/app/Ansible")
3136
else:
3237
ANSIBLE_BASE = Path(__file__).parent.parent.parent / "Ansible"
3338

39+
# Load history on startup
40+
def load_history():
41+
global history_store
42+
if HISTORY_FILE.exists():
43+
try:
44+
with open(HISTORY_FILE, 'r') as f:
45+
history_store = json.load(f)
46+
except:
47+
history_store = []
48+
else:
49+
history_store = []
50+
51+
def save_history():
52+
try:
53+
with open(HISTORY_FILE, 'w') as f:
54+
json.dump(history_store[-100:], f) # Keep last 100 executions
55+
except:
56+
pass
57+
58+
load_history()
59+
3460
class InventoryEntry(BaseModel):
3561
name: str
3662
host: str
@@ -57,10 +83,14 @@ class JobStatus(BaseModel):
5783
output: str
5884
started_at: str
5985
completed_at: Optional[str]
86+
return_code: Optional[int] = None
87+
duration: Optional[float] = None
88+
folder: str
89+
playbook: str
6090

6191
@app.get("/")
6292
async def root():
63-
return {"message": "Ansible Dashboard API"}
93+
return {"message": "Ansible Dashboard API v2", "version": "2.0.0"}
6494

6595
@app.get("/api/folders", response_model=List[AnsibleFolder])
6696
async def get_ansible_folders():
@@ -119,7 +149,6 @@ async def get_inventory(folder_name: str):
119149
for section in config.sections():
120150
hosts = []
121151
for key in config[section]:
122-
# Parse inventory line
123152
parts = key.split()
124153
host_info = {"name": parts[0] if parts else key}
125154

@@ -163,10 +192,8 @@ async def update_inventory(folder_name: str, content: Dict[str, Any]):
163192
inventory_file = folder_path / "inventory.ini"
164193

165194
if "raw" in content:
166-
# Save raw content
167195
inventory_file.write_text(content["raw"])
168196
else:
169-
# Generate from structured data
170197
lines = []
171198
for group, hosts in content.items():
172199
lines.append(f"[{group}]")
@@ -197,35 +224,59 @@ async def update_vars(folder_name: str, content: Dict[str, Any]):
197224
return {"success": True}
198225

199226
async def run_ansible_playbook(job_id: str, folder: str, playbook: str, inventory: str):
200-
"""Run ansible playbook in background"""
227+
"""Run ansible playbook in background with detailed output"""
201228
folder_path = ANSIBLE_BASE / folder
202229
playbook_path = folder_path / playbook
203230
inventory_path = folder_path / inventory
204231

205232
jobs_store[job_id]["status"] = "running"
233+
start_time = time.time()
206234

207235
try:
208-
# Change to the folder directory to respect ansible.cfg
236+
# Run with ANSI colors enabled
237+
env = os.environ.copy()
238+
env['ANSIBLE_FORCE_COLOR'] = 'true'
239+
209240
process = await asyncio.create_subprocess_exec(
210241
"ansible-playbook",
211242
"-i", str(inventory_path),
212243
str(playbook_path),
213244
cwd=str(folder_path),
214245
stdout=asyncio.subprocess.PIPE,
215-
stderr=asyncio.subprocess.STDOUT
246+
stderr=asyncio.subprocess.STDOUT,
247+
env=env
216248
)
217249

218250
output, _ = await process.communicate()
251+
duration = time.time() - start_time
219252

220253
jobs_store[job_id]["output"] = output.decode()
221254
jobs_store[job_id]["status"] = "completed" if process.returncode == 0 else "failed"
222255
jobs_store[job_id]["completed_at"] = datetime.now().isoformat()
223256
jobs_store[job_id]["return_code"] = process.returncode
257+
jobs_store[job_id]["duration"] = round(duration, 2)
258+
259+
# Save to history
260+
history_entry = {
261+
"job_id": job_id,
262+
"folder": folder,
263+
"playbook": playbook,
264+
"status": jobs_store[job_id]["status"],
265+
"started_at": jobs_store[job_id]["started_at"],
266+
"completed_at": jobs_store[job_id]["completed_at"],
267+
"duration": jobs_store[job_id]["duration"],
268+
"return_code": process.returncode,
269+
"output_preview": output.decode()[:500] # Store first 500 chars
270+
}
271+
history_store.append(history_entry)
272+
save_history()
224273

225274
except Exception as e:
275+
duration = time.time() - start_time
226276
jobs_store[job_id]["status"] = "error"
227277
jobs_store[job_id]["output"] = str(e)
228278
jobs_store[job_id]["completed_at"] = datetime.now().isoformat()
279+
jobs_store[job_id]["duration"] = round(duration, 2)
229280

230281
@app.post("/api/run")
231282
async def run_playbook(request: PlaybookRequest, background_tasks: BackgroundTasks):
@@ -239,7 +290,9 @@ async def run_playbook(request: PlaybookRequest, background_tasks: BackgroundTas
239290
"started_at": datetime.now().isoformat(),
240291
"completed_at": None,
241292
"folder": request.folder,
242-
"playbook": request.playbook
293+
"playbook": request.playbook,
294+
"duration": None,
295+
"return_code": None
243296
}
244297

245298
# Update vars if provided
@@ -269,9 +322,84 @@ async def get_job_status(job_id: str):
269322

270323
@app.get("/api/jobs")
271324
async def get_all_jobs():
272-
"""Get all jobs"""
325+
"""Get all active jobs"""
273326
return list(jobs_store.values())
274327

328+
@app.get("/api/history")
329+
async def get_history(limit: int = 50):
330+
"""Get execution history"""
331+
return history_store[-limit:][::-1] # Return last N, newest first
332+
333+
@app.get("/api/history/{job_id}")
334+
async def get_history_item(job_id: str):
335+
"""Get specific history item"""
336+
for item in history_store:
337+
if item["job_id"] == job_id:
338+
# Try to get full output from jobs_store if still available
339+
if job_id in jobs_store:
340+
item["output"] = jobs_store[job_id]["output"]
341+
return item
342+
raise HTTPException(status_code=404, detail="History item not found")
343+
344+
@app.get("/api/statistics")
345+
async def get_statistics():
346+
"""Get execution statistics"""
347+
total = len(history_store)
348+
if total == 0:
349+
return {
350+
"total_executions": 0,
351+
"successful": 0,
352+
"failed": 0,
353+
"success_rate": 0,
354+
"average_duration": 0,
355+
"most_used_folders": [],
356+
"recent_activity": []
357+
}
358+
359+
successful = sum(1 for h in history_store if h["status"] == "completed")
360+
failed = sum(1 for h in history_store if h["status"] == "failed")
361+
362+
durations = [h.get("duration", 0) for h in history_store if h.get("duration")]
363+
avg_duration = sum(durations) / len(durations) if durations else 0
364+
365+
# Most used folders
366+
folder_counts = {}
367+
for h in history_store:
368+
folder = h["folder"]
369+
folder_counts[folder] = folder_counts.get(folder, 0) + 1
370+
371+
most_used = sorted(folder_counts.items(), key=lambda x: x[1], reverse=True)[:5]
372+
373+
# Recent activity (last 24 hours)
374+
now = datetime.now()
375+
recent = []
376+
for h in history_store[-20:]:
377+
try:
378+
started = datetime.fromisoformat(h["started_at"])
379+
hours_ago = (now - started).total_seconds() / 3600
380+
if hours_ago <= 24:
381+
recent.append(h)
382+
except:
383+
pass
384+
385+
return {
386+
"total_executions": total,
387+
"successful": successful,
388+
"failed": failed,
389+
"success_rate": round((successful / total * 100) if total > 0 else 0, 1),
390+
"average_duration": round(avg_duration, 2),
391+
"most_used_folders": [{"name": name, "count": count} for name, count in most_used],
392+
"recent_activity": recent[::-1]
393+
}
394+
395+
@app.delete("/api/history")
396+
async def clear_history():
397+
"""Clear execution history"""
398+
global history_store
399+
history_store = []
400+
save_history()
401+
return {"success": True, "message": "History cleared"}
402+
275403
if __name__ == "__main__":
276404
import uvicorn
277405
uvicorn.run(app, host="0.0.0.0", port=8000)

β€Ždashboard/frontend/package.jsonβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
"lucide-react": "^0.294.0",
1818
"react-hot-toast": "^2.4.1",
1919
"clsx": "^2.0.0",
20-
"yaml": "^2.3.4"
20+
"yaml": "^2.3.4",
21+
"@monaco-editor/react": "^4.6.0",
22+
"ansi-to-html": "^0.7.2",
23+
"date-fns": "^3.0.0"
2124
},
2225
"devDependencies": {
2326
"@types/react": "^18.2.43",
@@ -32,6 +35,7 @@
3235
"postcss": "^8.4.32",
3336
"tailwindcss": "^3.3.6",
3437
"typescript": "^5.2.2",
35-
"vite": "^5.0.8"
38+
"vite": "^5.0.8",
39+
"@types/node": "^20.10.0"
3640
}
3741
}

0 commit comments

Comments
Β (0)