1010from pathlib import Path
1111import uuid
1212from datetime import datetime
13+ import json
14+ import time
1315
14- app = FastAPI (title = "Ansible Dashboard" )
16+ app = FastAPI (title = "Ansible Dashboard v2 " )
1517
1618app .add_middleware (
1719 CORSMiddleware ,
2426# Store for running jobs
2527jobs_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)
2934if Path ("/app/Ansible" ).exists ():
3035 ANSIBLE_BASE = Path ("/app/Ansible" )
3136else :
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+
3460class 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 ("/" )
6292async 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 ])
6696async 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
199226async 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" )
231282async 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" )
271324async 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+
275403if __name__ == "__main__" :
276404 import uvicorn
277405 uvicorn .run (app , host = "0.0.0.0" , port = 8000 )
0 commit comments