mcp-souschef 3.5.3__py3-none-any.whl → 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,964 @@
1
+ """History page for viewing past analyses and conversions."""
2
+
3
+ import json
4
+ import sys
5
+ import tarfile
6
+ import zipfile
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import pandas as pd
11
+ import streamlit as st
12
+
13
+ # Add the parent directory to sys.path
14
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
15
+
16
+ from souschef.storage import get_blob_storage, get_storage_manager
17
+
18
+ # Constants
19
+ DOWNLOAD_ARTEFACTS_LABEL = "Download Artefacts"
20
+
21
+
22
+ def show_history_page() -> None:
23
+ """Show the history page with past analyses and conversions."""
24
+ st.header("Analysis and Conversion History")
25
+ st.markdown("""
26
+ View your historical cookbook analyses and conversions. Download previously
27
+ generated assets and review past recommendations.
28
+ """)
29
+
30
+ # Get storage manager
31
+ storage_manager = get_storage_manager()
32
+ blob_storage = get_blob_storage()
33
+
34
+ # Tabs for different views
35
+ tab1, tab2, tab3 = st.tabs(["Analysis History", "Conversion History", "Statistics"])
36
+
37
+ with tab1:
38
+ _show_analysis_history(storage_manager)
39
+
40
+ with tab2:
41
+ _show_conversion_history(storage_manager, blob_storage)
42
+
43
+ with tab3:
44
+ _show_statistics(storage_manager)
45
+
46
+
47
+ def _show_analysis_history(storage_manager) -> None:
48
+ """Show analysis history tab."""
49
+ st.subheader("Analysis History")
50
+
51
+ # Filters
52
+ col1, col2 = st.columns([3, 1])
53
+
54
+ with col1:
55
+ cookbook_filter = st.text_input(
56
+ "Filter by cookbook name",
57
+ placeholder="Enter cookbook name...",
58
+ key="history_cookbook_filter",
59
+ )
60
+
61
+ with col2:
62
+ limit = st.selectbox(
63
+ "Show results",
64
+ [10, 25, 50, 100],
65
+ index=1,
66
+ key="history_limit",
67
+ )
68
+
69
+ # Get analysis history
70
+ if cookbook_filter:
71
+ analyses = storage_manager.get_analysis_history(
72
+ cookbook_name=cookbook_filter, limit=limit
73
+ )
74
+ else:
75
+ analyses = storage_manager.get_analysis_history(limit=limit)
76
+
77
+ if not analyses:
78
+ st.info("No analysis history found. Start by analysing a cookbook!")
79
+ return
80
+
81
+ # Display as table
82
+ st.write(f"**Total Results:** {len(analyses)}")
83
+
84
+ df_data = []
85
+ for analysis in analyses:
86
+ time_saved = analysis.estimated_hours - analysis.estimated_hours_with_souschef
87
+ df_data.append(
88
+ {
89
+ "Cookbook": analysis.cookbook_name,
90
+ "Version": analysis.cookbook_version,
91
+ "Complexity": analysis.complexity,
92
+ "Manual Hours": f"{analysis.estimated_hours:.1f}",
93
+ "AI Hours": f"{analysis.estimated_hours_with_souschef:.1f}",
94
+ "Time Saved": f"{time_saved:.1f}h",
95
+ "AI Provider": analysis.ai_provider or "Rule-based",
96
+ "Date": _format_datetime(analysis.created_at),
97
+ "ID": analysis.id,
98
+ }
99
+ )
100
+
101
+ df = pd.DataFrame(df_data)
102
+ st.dataframe(df, use_container_width=True, hide_index=True)
103
+
104
+ # Expandable details
105
+ st.subheader("Analysis Details")
106
+
107
+ selected_id = st.selectbox(
108
+ "Select analysis to view details",
109
+ options=[a.id for a in analyses],
110
+ format_func=lambda x: next(
111
+ f"{a.cookbook_name} - {_format_datetime(a.created_at)}"
112
+ for a in analyses
113
+ if a.id == x
114
+ ),
115
+ key="history_selected_analysis",
116
+ )
117
+
118
+ if selected_id:
119
+ selected = next(a for a in analyses if a.id == selected_id)
120
+ _display_analysis_details(selected)
121
+
122
+
123
+ def _display_analysis_details(analysis) -> None:
124
+ """Display detailed analysis information."""
125
+ col1, col2, col3, col4 = st.columns(4)
126
+
127
+ with col1:
128
+ st.metric("Complexity", analysis.complexity)
129
+
130
+ with col2:
131
+ st.metric("Manual Hours", f"{analysis.estimated_hours:.1f}")
132
+
133
+ with col3:
134
+ st.metric("AI-Assisted Hours", f"{analysis.estimated_hours_with_souschef:.1f}")
135
+
136
+ with col4:
137
+ time_saved = analysis.estimated_hours - analysis.estimated_hours_with_souschef
138
+ st.metric("Time Saved", f"{time_saved:.1f}h")
139
+
140
+ # Recommendations
141
+ st.markdown("### Recommendations")
142
+ st.text_area(
143
+ "Analysis Recommendations",
144
+ value=analysis.recommendations,
145
+ height=200,
146
+ disabled=True,
147
+ key=f"recommendations_{analysis.id}",
148
+ )
149
+
150
+ # Check if conversions exist for this analysis
151
+ storage_manager = get_storage_manager()
152
+ blob_storage = get_blob_storage()
153
+ conversions = storage_manager.get_conversions_by_analysis_id(analysis.id)
154
+
155
+ st.divider()
156
+
157
+ # Show conversion actions based on whether conversions exist
158
+ if conversions:
159
+ _display_conversion_actions(analysis, conversions, blob_storage)
160
+ else:
161
+ _display_convert_button(analysis, blob_storage)
162
+
163
+ # Full analysis data
164
+ with st.expander("View Full Analysis Data"):
165
+ try:
166
+ analysis_data = json.loads(analysis.analysis_data)
167
+ st.json(analysis_data)
168
+ except json.JSONDecodeError:
169
+ st.error("Unable to parse analysis data")
170
+
171
+
172
+ def _display_conversion_actions(analysis, conversions, blob_storage) -> None:
173
+ """
174
+ Display download buttons for existing conversions.
175
+
176
+ Args:
177
+ analysis: The analysis result.
178
+ conversions: List of conversion results for this analysis.
179
+ blob_storage: Blob storage instance.
180
+
181
+ """
182
+ st.markdown("### Conversions")
183
+ st.success(f"✅ {len(conversions)} conversion(s) available for this analysis")
184
+
185
+ # Show most recent conversion
186
+ latest_conversion = conversions[0]
187
+
188
+ col1, col2 = st.columns([3, 1])
189
+ with col1:
190
+ st.write(f"**Status:** {latest_conversion.status}")
191
+ st.write(f"**Output Type:** {latest_conversion.output_type}")
192
+ st.write(f"**Files Generated:** {latest_conversion.files_generated}")
193
+ st.write(f"**Date:** {_format_datetime(latest_conversion.created_at)}")
194
+
195
+ with col2:
196
+ if latest_conversion.blob_storage_key and st.button(
197
+ DOWNLOAD_ARTEFACTS_LABEL,
198
+ type="primary",
199
+ key=f"download_conversion_{analysis.id}",
200
+ ):
201
+ _download_conversion_artefacts(latest_conversion, blob_storage)
202
+
203
+ # Show all conversions in expander if there are multiple
204
+ if len(conversions) > 1:
205
+ with st.expander(f"View All {len(conversions)} Conversions"):
206
+ for idx, conv in enumerate(conversions):
207
+ date_status = _format_datetime(conv.created_at)
208
+ st.write(f"**{idx + 1}. {date_status}** - {conv.status}")
209
+ if conv.blob_storage_key and st.button(
210
+ "Download",
211
+ key=f"download_old_conversion_{conv.id}",
212
+ ):
213
+ _download_conversion_artefacts(conv, blob_storage)
214
+
215
+
216
+ def _display_convert_button(analysis, blob_storage) -> None:
217
+ """
218
+ Display convert button for analyses without conversions.
219
+
220
+ Args:
221
+ analysis: The analysis result.
222
+ blob_storage: Blob storage instance.
223
+
224
+ """
225
+ st.markdown("### Conversion")
226
+ st.info(
227
+ "No conversions found for this analysis. "
228
+ "Convert the cookbook to Ansible playbooks."
229
+ )
230
+
231
+ col1, col2 = st.columns([3, 1])
232
+ with col1:
233
+ st.write(f"**Cookbook:** {analysis.cookbook_name}")
234
+ st.write(f"**Version:** {analysis.cookbook_version}")
235
+ if analysis.cookbook_blob_key:
236
+ st.write("✅ Original cookbook archive available")
237
+ else:
238
+ st.warning("⚠️ Original cookbook archive not found in storage")
239
+
240
+ with col2:
241
+ if analysis.cookbook_blob_key:
242
+ if st.button(
243
+ "Convert to Ansible",
244
+ type="primary",
245
+ key=f"convert_analysis_{analysis.id}",
246
+ ):
247
+ _trigger_conversion(analysis, blob_storage)
248
+ else:
249
+ st.button(
250
+ "Convert to Ansible",
251
+ type="primary",
252
+ disabled=True,
253
+ key=f"convert_analysis_{analysis.id}_disabled",
254
+ )
255
+ st.caption("Cannot convert: original cookbook not in storage")
256
+
257
+
258
+ def _trigger_conversion(analysis, blob_storage) -> None:
259
+ """
260
+ Trigger conversion for an analysis from history.
261
+
262
+ Args:
263
+ analysis: The analysis result to convert.
264
+ blob_storage: Blob storage instance.
265
+
266
+ """
267
+ import tempfile
268
+ from pathlib import Path
269
+
270
+ try:
271
+ with st.spinner("Downloading cookbook and preparing conversion..."):
272
+ # Download the original cookbook from blob storage
273
+ temp_dir = Path(tempfile.mkdtemp(prefix="souschef_history_convert_"))
274
+ # Download without assuming the format - we'll detect it
275
+ cookbook_path = blob_storage.download(
276
+ analysis.cookbook_blob_key,
277
+ temp_dir / f"{analysis.cookbook_name}_archive",
278
+ )
279
+
280
+ if not cookbook_path or not cookbook_path.exists():
281
+ st.error("Failed to download cookbook from storage")
282
+ return
283
+
284
+ # Detect the archive format
285
+ archive_format = _detect_archive_format(cookbook_path)
286
+ if not archive_format:
287
+ st.error(
288
+ "Unable to detect archive format. "
289
+ "Supported formats: ZIP, TAR, TAR.GZ, TAR.BZ2, TAR.XZ"
290
+ )
291
+ return
292
+
293
+ extract_dir = temp_dir / "extracted"
294
+ extract_dir.mkdir(parents=True, exist_ok=True)
295
+
296
+ # Security limits for extraction (prevent zip bombs and resource exhaustion)
297
+ max_file_size = 100 * 1024 * 1024 # 100 MB per file
298
+ max_total_size = 500 * 1024 * 1024 # 500 MB total
299
+ max_files = 10000 # Maximum number of files
300
+
301
+ # Extract based on detected format
302
+ if archive_format == "zip":
303
+ _extract_zip_safely(
304
+ cookbook_path, extract_dir, max_file_size, max_total_size, max_files
305
+ )
306
+ elif archive_format == "tar.gz":
307
+ with tarfile.open(cookbook_path, "r:gz") as tar:
308
+ safe_members = _filter_safe_tar_members(
309
+ tar, extract_dir, max_file_size, max_total_size, max_files
310
+ )
311
+ tar.extractall(extract_dir, members=safe_members)
312
+ elif archive_format == "tar.bz2":
313
+ with tarfile.open(cookbook_path, "r:bz2") as tar:
314
+ safe_members = _filter_safe_tar_members(
315
+ tar, extract_dir, max_file_size, max_total_size, max_files
316
+ )
317
+ tar.extractall(extract_dir, members=safe_members)
318
+ elif archive_format == "tar.xz":
319
+ with tarfile.open(cookbook_path, "r:xz") as tar:
320
+ safe_members = _filter_safe_tar_members(
321
+ tar, extract_dir, max_file_size, max_total_size, max_files
322
+ )
323
+ tar.extractall(extract_dir, members=safe_members)
324
+ elif archive_format == "tar":
325
+ with tarfile.open(cookbook_path, "r") as tar:
326
+ safe_members = _filter_safe_tar_members(
327
+ tar, extract_dir, max_file_size, max_total_size, max_files
328
+ )
329
+ tar.extractall(extract_dir, members=safe_members)
330
+
331
+ # Find the cookbook directory (should be the only directory in extracted/)
332
+ cookbook_dirs = [d for d in extract_dir.iterdir() if d.is_dir()]
333
+ if not cookbook_dirs:
334
+ st.error("No cookbook directory found in archive")
335
+ return
336
+
337
+ cookbook_dir = cookbook_dirs[0]
338
+
339
+ # Store in session state and direct user to cookbook analysis page
340
+ st.session_state.history_convert_path = str(cookbook_dir)
341
+ st.session_state.history_convert_analysis_id = analysis.id
342
+ st.session_state.history_convert_cookbook_name = analysis.cookbook_name
343
+
344
+ st.success("✅ Cookbook downloaded and ready for conversion!")
345
+ st.info(
346
+ "Please navigate to the **Cookbook Analysis** page "
347
+ "to complete the conversion."
348
+ )
349
+ st.markdown("""
350
+ The cookbook has been downloaded from storage and is ready for conversion.
351
+ Go to the Cookbook Analysis page where you'll find a "Convert" button to
352
+ generate Ansible playbooks.
353
+ """)
354
+
355
+ except Exception as e:
356
+ st.error(f"Failed to prepare conversion: {e}")
357
+ import traceback
358
+
359
+ st.error(traceback.format_exc())
360
+
361
+
362
+ def _filter_safe_tar_members(
363
+ tar, extract_dir: Path, max_file_size: int, max_total_size: int, max_files: int
364
+ ):
365
+ """
366
+ Filter tar archive members to only include safe ones.
367
+
368
+ Args:
369
+ tar: Opened tar archive.
370
+ extract_dir: Directory where files will be extracted.
371
+ max_file_size: Maximum size per file in bytes.
372
+ max_total_size: Maximum total extraction size in bytes.
373
+ max_files: Maximum number of files to extract.
374
+
375
+ Returns:
376
+ List of safe tar members to extract.
377
+
378
+ """
379
+ safe_members = []
380
+ total_size = 0
381
+
382
+ for file_count, member in enumerate(tar.getmembers()):
383
+ validation_result = _validate_tar_member(
384
+ member,
385
+ extract_dir,
386
+ file_count,
387
+ total_size,
388
+ max_file_size,
389
+ max_total_size,
390
+ max_files,
391
+ )
392
+
393
+ if validation_result["warning"]:
394
+ st.warning(validation_result["warning"])
395
+
396
+ if validation_result["should_stop"]:
397
+ break
398
+
399
+ if validation_result["is_safe"]:
400
+ safe_members.append(member)
401
+ total_size = validation_result["new_total_size"]
402
+
403
+ return safe_members
404
+
405
+
406
+ def _detect_archive_format(file_path: Path) -> str | None:
407
+ """
408
+ Detect archive format by attempting to open with different methods.
409
+
410
+ Args:
411
+ file_path: Path to the archive file.
412
+
413
+ Returns:
414
+ Format string: 'zip', 'tar.gz', 'tar.bz2', 'tar.xz', 'tar',
415
+ or None if format cannot be detected.
416
+
417
+ """
418
+ # Try ZIP first
419
+ try:
420
+ with zipfile.ZipFile(file_path, "r") as zf:
421
+ # Verify it's a valid ZIP by checking if we can list contents
422
+ _ = zf.namelist()
423
+ return "zip"
424
+ except (zipfile.BadZipFile, OSError):
425
+ pass
426
+
427
+ # Try gzipped tar (.tar.gz, .tgz)
428
+ try:
429
+ with tarfile.open(file_path, "r:gz") as tf:
430
+ # Verify it's valid by trying to get members
431
+ _ = tf.getmembers()
432
+ return "tar.gz"
433
+ except (tarfile.ReadError, OSError):
434
+ pass
435
+
436
+ # Try bzip2 compressed tar (.tar.bz2, .tbz2)
437
+ try:
438
+ with tarfile.open(file_path, "r:bz2") as tf:
439
+ # Verify it's valid by trying to get members
440
+ _ = tf.getmembers()
441
+ return "tar.bz2"
442
+ except (tarfile.ReadError, OSError):
443
+ pass
444
+
445
+ # Try xz compressed tar (.tar.xz, .txz)
446
+ try:
447
+ with tarfile.open(file_path, "r:xz") as tf:
448
+ # Verify it's valid by trying to get members
449
+ _ = tf.getmembers()
450
+ return "tar.xz"
451
+ except (tarfile.ReadError, OSError):
452
+ pass
453
+
454
+ # Try plain tar
455
+ try:
456
+ with tarfile.open(file_path, "r") as tf:
457
+ # Verify it's valid by trying to get members
458
+ _ = tf.getmembers()
459
+ return "tar"
460
+ except (tarfile.ReadError, OSError):
461
+ pass
462
+
463
+ return None
464
+
465
+
466
+ def _extract_zip_safely(
467
+ zip_path: Path,
468
+ extract_dir: Path,
469
+ max_file_size: int,
470
+ max_total_size: int,
471
+ max_files: int,
472
+ ) -> None:
473
+ """
474
+ Safely extract ZIP archive with security validations.
475
+
476
+ Args:
477
+ zip_path: Path to ZIP archive.
478
+ extract_dir: Directory to extract to.
479
+ max_file_size: Maximum size per file in bytes.
480
+ max_total_size: Maximum total extraction size in bytes.
481
+ max_files: Maximum number of files to extract.
482
+
483
+ """
484
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
485
+ safe_members = _filter_safe_zip_members(
486
+ zip_ref, max_files, max_file_size, max_total_size
487
+ )
488
+ _extract_zip_members(zip_ref, extract_dir, safe_members)
489
+
490
+
491
+ def _filter_safe_zip_members(
492
+ zip_ref: zipfile.ZipFile, max_files: int, max_file_size: int, max_total_size: int
493
+ ) -> list[zipfile.ZipInfo]:
494
+ """
495
+ Filter ZIP archive members to only include safe ones.
496
+
497
+ Args:
498
+ zip_ref: Open ZipFile reference.
499
+ max_files: Maximum number of files.
500
+ max_file_size: Maximum size per file in bytes.
501
+ max_total_size: Maximum total extraction size in bytes.
502
+
503
+ Returns:
504
+ List of safe ZipInfo members to extract.
505
+
506
+ """
507
+ safe_members = []
508
+ total_size = 0
509
+
510
+ for file_count, info in enumerate(zip_ref.filelist, start=1):
511
+ # Check file count limit
512
+ if file_count > max_files:
513
+ st.warning(f"Too many files in archive (limit: {max_files})")
514
+ break
515
+
516
+ # Check individual file size limit
517
+ if info.file_size > max_file_size:
518
+ st.warning(f"Skipping large file: {info.filename}")
519
+ continue
520
+
521
+ # Check total size limit
522
+ if total_size + info.file_size > max_total_size:
523
+ st.warning(f"Total extraction size limit reached ({max_total_size} bytes)")
524
+ break
525
+
526
+ # Check for path traversal attacks
527
+ if ".." in info.filename or info.filename.startswith("/"):
528
+ st.warning(f"Skipping file with suspicious path: {info.filename}")
529
+ continue
530
+
531
+ # File is safe, add to list
532
+ safe_members.append(info)
533
+ total_size += info.file_size
534
+
535
+ return safe_members
536
+
537
+
538
+ def _extract_zip_members(
539
+ zip_ref: zipfile.ZipFile, extract_dir: Path, safe_members: list[zipfile.ZipInfo]
540
+ ) -> None:
541
+ """
542
+ Extract ZIP members safely after validation.
543
+
544
+ Args:
545
+ zip_ref: Open ZipFile reference.
546
+ extract_dir: Directory to extract to.
547
+ safe_members: List of validated safe members to extract.
548
+
549
+ """
550
+ for info in safe_members:
551
+ try:
552
+ _extract_single_zip_member(zip_ref, info, extract_dir)
553
+ except Exception as e:
554
+ st.warning(f"Failed to extract {info.filename}: {e}")
555
+
556
+
557
+ def _extract_single_zip_member(
558
+ zip_ref: zipfile.ZipFile, info: zipfile.ZipInfo, extract_dir: Path
559
+ ) -> None:
560
+ """
561
+ Extract a single ZIP member with path validation.
562
+
563
+ Args:
564
+ zip_ref: Open ZipFile reference.
565
+ info: ZipInfo for the member.
566
+ extract_dir: Directory to extract to.
567
+
568
+ """
569
+ member_path = (extract_dir / info.filename).resolve()
570
+
571
+ if not str(member_path).startswith(str(extract_dir.resolve())):
572
+ st.warning(f"Skipping file outside extraction directory: {info.filename}")
573
+ return
574
+
575
+ if info.is_dir():
576
+ member_path.mkdir(parents=True, exist_ok=True)
577
+ else:
578
+ member_path.parent.mkdir(parents=True, exist_ok=True)
579
+ with zip_ref.open(info) as source, member_path.open("wb") as target:
580
+ while True:
581
+ chunk = source.read(8192)
582
+ if not chunk:
583
+ break
584
+ target.write(chunk)
585
+
586
+
587
+ def _validate_tar_member(
588
+ member,
589
+ extract_dir: Path,
590
+ file_count: int,
591
+ total_size: int,
592
+ max_file_size: int,
593
+ max_total_size: int,
594
+ max_files: int,
595
+ ) -> dict:
596
+ """
597
+ Validate a tar archive member for safe extraction.
598
+
599
+ Args:
600
+ member: Tar member to validate.
601
+ extract_dir: Directory where files will be extracted.
602
+ file_count: Current file count.
603
+ total_size: Current total size in bytes.
604
+ max_file_size: Maximum size per file in bytes.
605
+ max_total_size: Maximum total extraction size in bytes.
606
+ max_files: Maximum number of files.
607
+
608
+ Returns:
609
+ Dictionary with validation results:
610
+ - is_safe: Whether member should be extracted
611
+ - new_total_size: Updated total size
612
+ - warning: Warning message if any
613
+ - should_stop: Whether to stop processing more members
614
+
615
+ """
616
+ # Check file count limit
617
+ if file_count >= max_files:
618
+ return {
619
+ "is_safe": False,
620
+ "new_total_size": total_size,
621
+ "warning": (
622
+ f"Extraction stopped: archive contains more than {max_files} files"
623
+ ),
624
+ "should_stop": True,
625
+ }
626
+
627
+ # Check individual file size
628
+ if member.size > max_file_size:
629
+ size_mb = member.size / (1024 * 1024)
630
+ limit_mb = max_file_size / (1024 * 1024)
631
+ return {
632
+ "is_safe": False,
633
+ "new_total_size": total_size,
634
+ "warning": (
635
+ f"Skipping large file {member.name}: "
636
+ f"{size_mb:.1f}MB exceeds {limit_mb:.0f}MB limit"
637
+ ),
638
+ "should_stop": False,
639
+ }
640
+
641
+ # Check total extraction size
642
+ new_total_size = total_size + member.size
643
+ if new_total_size > max_total_size:
644
+ limit_mb = max_total_size / (1024 * 1024)
645
+ return {
646
+ "is_safe": False,
647
+ "new_total_size": total_size,
648
+ "warning": f"Extraction stopped: total size exceeds {limit_mb:.0f}MB limit",
649
+ "should_stop": True,
650
+ }
651
+
652
+ # Validate path security
653
+ member_path = (extract_dir / member.name).resolve()
654
+ if not str(member_path).startswith(str(extract_dir.resolve())):
655
+ return {
656
+ "is_safe": False,
657
+ "new_total_size": total_size,
658
+ "warning": f"Skipping potentially unsafe path: {member.name}",
659
+ "should_stop": False,
660
+ }
661
+
662
+ # Validate symlink security
663
+ if member.issym() or member.islnk():
664
+ link_target = (extract_dir / member.linkname).resolve()
665
+ if not str(link_target).startswith(str(extract_dir.resolve())):
666
+ return {
667
+ "is_safe": False,
668
+ "new_total_size": total_size,
669
+ "warning": f"Skipping unsafe symlink: {member.name}",
670
+ "should_stop": False,
671
+ }
672
+
673
+ # Member is safe
674
+ return {
675
+ "is_safe": True,
676
+ "new_total_size": new_total_size,
677
+ "warning": None,
678
+ "should_stop": False,
679
+ }
680
+
681
+
682
+ def _show_conversion_history(storage_manager, blob_storage) -> None:
683
+ """Show conversion history tab."""
684
+ st.subheader("Conversion History")
685
+
686
+ # Filters
687
+ col1, col2, col3 = st.columns([2, 1, 1])
688
+
689
+ with col1:
690
+ cookbook_filter = st.text_input(
691
+ "Filter by cookbook name",
692
+ placeholder="Enter cookbook name...",
693
+ key="conversion_cookbook_filter",
694
+ )
695
+
696
+ with col2:
697
+ status_filter = st.selectbox(
698
+ "Filter by status",
699
+ ["All", "success", "partial", "failed"],
700
+ key="conversion_status_filter",
701
+ )
702
+
703
+ with col3:
704
+ limit = st.selectbox(
705
+ "Show results",
706
+ [10, 25, 50, 100],
707
+ index=1,
708
+ key="conversion_limit",
709
+ )
710
+
711
+ # Get conversion history
712
+ if cookbook_filter:
713
+ conversions = storage_manager.get_conversion_history(
714
+ cookbook_name=cookbook_filter, limit=limit
715
+ )
716
+ else:
717
+ conversions = storage_manager.get_conversion_history(limit=limit)
718
+
719
+ # Filter by status
720
+ if status_filter != "All":
721
+ conversions = [c for c in conversions if c.status == status_filter]
722
+
723
+ if not conversions:
724
+ st.info("No conversion history found. Start by converting a cookbook!")
725
+ return
726
+
727
+ # Display as table
728
+ st.write(f"**Total Results:** {len(conversions)}")
729
+
730
+ df_data = []
731
+ for conversion in conversions:
732
+ df_data.append(
733
+ {
734
+ "Status": conversion.status,
735
+ "Cookbook": conversion.cookbook_name,
736
+ "Output Type": conversion.output_type,
737
+ "Files Generated": conversion.files_generated,
738
+ "Has Artefacts": "Yes" if conversion.blob_storage_key else "No",
739
+ "Date": _format_datetime(conversion.created_at),
740
+ "ID": conversion.id,
741
+ }
742
+ )
743
+
744
+ df = pd.DataFrame(df_data)
745
+ st.dataframe(df, use_container_width=True, hide_index=True)
746
+
747
+ # Download artifacts
748
+ st.subheader("Download Artefacts")
749
+
750
+ selected_id = st.selectbox(
751
+ "Select conversion to download",
752
+ options=[c.id for c in conversions if c.blob_storage_key],
753
+ format_func=lambda x: next(
754
+ f"{c.cookbook_name} - {_format_datetime(c.created_at)}"
755
+ for c in conversions
756
+ if c.id == x
757
+ ),
758
+ key="history_selected_conversion",
759
+ )
760
+
761
+ if selected_id:
762
+ selected = next(c for c in conversions if c.id == selected_id)
763
+ if selected.blob_storage_key:
764
+ col1, col2 = st.columns([3, 1])
765
+
766
+ with col1:
767
+ st.write(f"**Cookbook:** {selected.cookbook_name}")
768
+ st.write(f"**Output Type:** {selected.output_type}")
769
+ st.write(f"**Files Generated:** {selected.files_generated}")
770
+
771
+ with col2:
772
+ if st.button(
773
+ DOWNLOAD_ARTEFACTS_LABEL,
774
+ type="primary",
775
+ key=f"download_{selected.id}",
776
+ ):
777
+ _download_conversion_artefacts(selected, blob_storage)
778
+
779
+
780
+ def _download_conversion_artefacts(conversion, blob_storage) -> None:
781
+ """Download conversion artefacts from blob storage."""
782
+ import tempfile
783
+
784
+ try:
785
+ with st.spinner("Preparing downloads..."):
786
+ # Parse conversion data to get blob keys
787
+ roles_blob_key, repo_blob_key = _parse_conversion_blob_keys(conversion)
788
+
789
+ # Download and display archives
790
+ temp_dir = Path(tempfile.mkdtemp())
791
+ _display_roles_download(conversion, blob_storage, roles_blob_key, temp_dir)
792
+ _display_repo_download(conversion, blob_storage, repo_blob_key, temp_dir)
793
+
794
+ st.success("✅ Archives ready for download!")
795
+
796
+ except Exception as e:
797
+ st.error(f"Failed to download artefacts: {e}")
798
+
799
+
800
+ def _parse_conversion_blob_keys(conversion) -> tuple[str, str | None]:
801
+ """Parse conversion data to extract blob storage keys."""
802
+ try:
803
+ conversion_data = json.loads(conversion.conversion_data)
804
+ roles_blob_key = conversion_data.get(
805
+ "roles_blob_key", conversion.blob_storage_key
806
+ )
807
+ repo_blob_key = conversion_data.get("repo_blob_key")
808
+ except (json.JSONDecodeError, AttributeError):
809
+ roles_blob_key = conversion.blob_storage_key
810
+ repo_blob_key = None
811
+ return roles_blob_key, repo_blob_key
812
+
813
+
814
+ def _display_roles_download(
815
+ conversion, blob_storage, roles_blob_key: str, temp_dir: Path
816
+ ) -> None:
817
+ """Download and display roles archive download button."""
818
+ roles_path = blob_storage.download(roles_blob_key, temp_dir / "roles_archive")
819
+
820
+ if not roles_path.exists():
821
+ return
822
+
823
+ if roles_path.is_file():
824
+ with roles_path.open("rb") as f:
825
+ st.download_button(
826
+ label="Download Roles Archive",
827
+ data=f.read(),
828
+ file_name=f"{conversion.cookbook_name}_roles.tar.gz",
829
+ mime="application/gzip",
830
+ key=f"download_roles_{conversion.id}",
831
+ )
832
+ else:
833
+ _create_and_display_zip_download(
834
+ roles_path,
835
+ f"{conversion.cookbook_name}_roles.zip",
836
+ "Download Roles Archive",
837
+ f"download_roles_{conversion.id}",
838
+ )
839
+
840
+
841
+ def _display_repo_download(
842
+ conversion, blob_storage, repo_blob_key: str | None, temp_dir: Path
843
+ ) -> None:
844
+ """Download and display repository archive download button if available."""
845
+ if not repo_blob_key:
846
+ return
847
+
848
+ repo_path = blob_storage.download(repo_blob_key, temp_dir / "repo_archive")
849
+
850
+ if not repo_path.exists():
851
+ return
852
+
853
+ if repo_path.is_file():
854
+ with repo_path.open("rb") as f:
855
+ st.download_button(
856
+ label="Download Repository Archive",
857
+ data=f.read(),
858
+ file_name=f"{conversion.cookbook_name}_repository.tar.gz",
859
+ mime="application/gzip",
860
+ key=f"download_repo_{conversion.id}",
861
+ )
862
+ else:
863
+ _create_and_display_zip_download(
864
+ repo_path,
865
+ f"{conversion.cookbook_name}_repository.zip",
866
+ "Download Repository Archive",
867
+ f"download_repo_{conversion.id}",
868
+ )
869
+
870
+
871
+ def _create_and_display_zip_download(
872
+ source_path: Path, file_name: str, label: str, key: str
873
+ ) -> None:
874
+ """Create a ZIP archive from directory and display download button."""
875
+ import io
876
+
877
+ zip_buffer = io.BytesIO()
878
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
879
+ for file_path in source_path.rglob("*"):
880
+ if file_path.is_file():
881
+ arcname = file_path.relative_to(source_path)
882
+ zipf.write(file_path, arcname)
883
+
884
+ zip_buffer.seek(0)
885
+ st.download_button(
886
+ label=label,
887
+ data=zip_buffer.getvalue(),
888
+ file_name=file_name,
889
+ mime="application/zip",
890
+ key=key,
891
+ )
892
+
893
+
894
+ def _show_statistics(storage_manager) -> None:
895
+ """Show statistics tab."""
896
+ st.subheader("Overall Statistics")
897
+
898
+ stats = storage_manager.get_statistics()
899
+
900
+ # Main metrics
901
+ col1, col2, col3 = st.columns(3)
902
+
903
+ with col1:
904
+ st.metric("Total Analyses", stats["total_analyses"])
905
+ st.metric("Unique Cookbooks", stats["unique_cookbooks_analysed"])
906
+
907
+ with col2:
908
+ st.metric("Total Conversions", stats["total_conversions"])
909
+ st.metric("Successful Conversions", stats["successful_conversions"])
910
+
911
+ with col3:
912
+ success_rate = (
913
+ (stats["successful_conversions"] / stats["total_conversions"] * 100)
914
+ if stats["total_conversions"] > 0
915
+ else 0
916
+ )
917
+ st.metric("Success Rate", f"{success_rate:.1f}%")
918
+ st.metric("Files Generated", stats["total_files_generated"])
919
+
920
+ st.divider()
921
+
922
+ # Effort savings
923
+ st.subheader("Effort Savings Analysis")
924
+
925
+ col1, col2, col3 = st.columns(3)
926
+
927
+ with col1:
928
+ st.metric("Avg Manual Hours", f"{stats['avg_manual_hours']:.1f}")
929
+
930
+ with col2:
931
+ st.metric("Avg AI-Assisted Hours", f"{stats['avg_ai_hours']:.1f}")
932
+
933
+ with col3:
934
+ time_saved = stats["avg_manual_hours"] - stats["avg_ai_hours"]
935
+ st.metric("Avg Time Saved", f"{time_saved:.1f}h")
936
+
937
+ # Calculate total time saved
938
+ if stats["total_analyses"] > 0:
939
+ total_manual = stats["avg_manual_hours"] * stats["total_analyses"]
940
+ total_ai = stats["avg_ai_hours"] * stats["total_analyses"]
941
+ total_saved = total_manual - total_ai
942
+
943
+ st.info(
944
+ f"**Total Time Saved Across All Analyses:** {total_saved:.1f} hours "
945
+ f"({total_saved / 8:.1f} work days)"
946
+ )
947
+
948
+
949
+ def _format_datetime(dt_str: str) -> str:
950
+ """
951
+ Format datetime string for display.
952
+
953
+ Args:
954
+ dt_str: DateTime string from database.
955
+
956
+ Returns:
957
+ Formatted datetime string.
958
+
959
+ """
960
+ try:
961
+ dt = datetime.fromisoformat(dt_str)
962
+ return dt.strftime("%Y-%m-%d %H:%M")
963
+ except (ValueError, TypeError):
964
+ return dt_str