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.
- {mcp_souschef-3.5.3.dist-info → mcp_souschef-4.0.0.dist-info}/METADATA +136 -8
- {mcp_souschef-3.5.3.dist-info → mcp_souschef-4.0.0.dist-info}/RECORD +17 -10
- souschef/core/ai_schemas.py +6 -1
- souschef/github/__init__.py +17 -0
- souschef/github/agent_control.py +459 -0
- souschef/server.py +193 -0
- souschef/storage/__init__.py +39 -0
- souschef/storage/blob.py +331 -0
- souschef/storage/config.py +163 -0
- souschef/storage/database.py +1182 -0
- souschef/ui/app.py +17 -4
- souschef/ui/pages/chef_server_settings.py +411 -2
- souschef/ui/pages/cookbook_analysis.py +352 -6
- souschef/ui/pages/history.py +964 -0
- {mcp_souschef-3.5.3.dist-info → mcp_souschef-4.0.0.dist-info}/WHEEL +0 -0
- {mcp_souschef-3.5.3.dist-info → mcp_souschef-4.0.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-3.5.3.dist-info → mcp_souschef-4.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|