feat: remove legacy frontend, root routing, compliance ZIP with Chromium PDF, chat markdown styling

- Legacy frontend removed: React SPA served at / (no /app/ prefix)
- Compliance report ZIP: Chromium headless --print-to-pdf (identical to browser) + CSV findings
- Server-side generation state: .compliance_generating marker survives page navigation
- RAG enriched: Description + Rationale + Remediation + Audit extracted from CIS vector chunks
- Chat markdown: syntax highlighting (Catppuccin), styled headings/lists/tables/blockquotes/code
- Default model auto-selected on Chat Agent load
This commit is contained in:
nogueiraguh
2026-03-20 07:21:41 -03:00
parent 5028f632a6
commit 61fbb3861d
18 changed files with 471 additions and 200 deletions

View File

@@ -4140,6 +4140,16 @@ def _extract_remediation_section(text: str) -> str:
return _extract_section(text, "Remediation")
def _extract_audit_section(text: str) -> str:
"""Extract the Audit section (verification steps)."""
return _extract_section(text, "Audit")
def _extract_rationale_section(text: str) -> str:
"""Extract the Rationale section (why this matters)."""
return _extract_section(text, "Rationale")
def _extract_description_section(text: str) -> str:
"""Extract the Description section (what this CIS finding is about)."""
return _extract_section(text, "Description")
@@ -4268,11 +4278,28 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
if matched:
best = matched[0]
desc = _extract_description_section(best)
rationale = _extract_rationale_section(best)
rem = _extract_remediation_section(best)
# Try second chunk if first has no remediation
if not rem and len(matched) > 1:
rem = _extract_remediation_section(matched[1])
return {"description": desc, "remediation": rem}
audit = _extract_audit_section(best)
# Try additional chunks for fuller content
for extra in matched[1:3]:
if not rem:
rem = _extract_remediation_section(extra)
elif len(rem) < 200:
extra_rem = _extract_remediation_section(extra)
if extra_rem and extra_rem not in rem:
rem = rem.rstrip() + "\n\n" + extra_rem
if not audit:
audit = _extract_audit_section(extra)
if not desc:
desc = _extract_description_section(extra)
if not rationale:
rationale = _extract_rationale_section(extra)
# Combine remediation + audit for complete guidance
full_rem = rem or ""
if audit and len(audit) > 50:
full_rem = (full_rem + "\n\nAudit / Verification:\n" + audit).strip() if full_rem else audit
return {"description": desc, "rationale": rationale, "remediation": full_rem}
except Exception as e:
log.warning(f"CIS remediation search failed for {adb_cfg.get('config_name','?')}: {e}")
return {"description": "", "remediation": ""}
@@ -4339,8 +4366,9 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date:
csv_remediation = (r.get("Remediation", "") or "").strip()
rag_data = r.get("__rag_remediation", {})
if isinstance(rag_data, str):
rag_data = {"description": "", "remediation": rag_data}
rag_data = {"description": "", "rationale": "", "remediation": rag_data}
rag_desc = (rag_data.get("description", "") or "").strip()
rag_rationale = (rag_data.get("rationale", "") or "").strip()
rag_rem = (rag_data.get("remediation", "") or "").strip()
# Status line
@@ -4352,12 +4380,17 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date:
detail = f" ({findings_num} of {esc(total_items)} items)"
result_html = f'<div class="result-box fail"><b>Result:</b> Tenancy {esc(tenancy_name)}: <span class="fail-text">Non-Compliant{detail}</span></div>'
# Description (what this CIS finding is about)
# Description
description_html = ""
if not is_compliant and rag_desc:
description_html = f'<div class="finding-desc"><h4>Description:</h4><p>{esc(rag_desc)}</p></div>'
# Remediation (only for non-compliant) — formatted with code snippets
# Rationale (why this matters)
rationale_html = ""
if not is_compliant and rag_rationale:
rationale_html = f'<div class="finding-rationale"><h4>Rationale:</h4><p>{esc(rag_rationale)}</p></div>'
# Remediation — formatted with code snippets
remediation_html = ""
if not is_compliant:
rem_text = rag_rem or csv_remediation
@@ -4387,6 +4420,7 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date:
{result_html}
{findings_file_html}
{description_html}
{rationale_html}
{remediation_html}
</div>'''
@@ -4555,6 +4589,11 @@ table.benchmark .section-header td {{ background:#e8ece8; font-weight:700; font-
.finding-desc h4 {{ font-size:11.5px; margin:0 0 6px; color:#2f5597; font-weight:700; }}
.finding-desc p {{ font-size:11px; line-height:1.65; text-align:justify; color:#374151; margin:0; padding:10px 14px; background:#f8f9fa; border-left:3px solid #2f5597; }}
/* Rationale */
.finding-rationale {{ margin:10px 0 0; }}
.finding-rationale h4 {{ font-size:11.5px; margin:0 0 6px; color:#92400e; font-weight:700; }}
.finding-rationale p {{ font-size:11px; line-height:1.65; text-align:justify; color:#374151; margin:0; padding:10px 14px; background:#fffbeb; border-left:3px solid #f59e0b; }}
/* Remediation */
.remediation {{ margin:10px 0 0; }}
.remediation h4 {{ font-size:11.5px; margin:0 0 6px; color:var(--oracle-red); font-weight:700; }}
@@ -4823,7 +4862,11 @@ async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(cu
if not csv_path.exists():
raise HTTPException(404, "cis_summary_report.csv not found")
# Delete existing cache to signal "generating"
# Mark as generating BEFORE deleting cache (prevents race condition)
marker = rdir / ".compliance_generating"
marker.write_text(str(uuid.uuid4()), encoding="utf-8")
# Delete existing cache
cache_path = rdir / "compliance_report.html"
if cache_path.exists():
cache_path.unlink()
@@ -4844,6 +4887,11 @@ async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(cu
log.info(f"Compliance report generation completed for {rid}")
except Exception as e:
log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True)
finally:
# Remove generating marker
m = REPORTS / rid / ".compliance_generating"
if m.exists():
m.unlink(missing_ok=True)
_chat_executor.submit(_bg_generate)
return {"status": "generating", "has_rag": has_adb}
@@ -4851,9 +4899,90 @@ async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(cu
@app.get("/api/reports/{rid}/compliance-report/status")
async def compliance_report_status(rid: str):
"""Check if the compliance report exists."""
cache_path = REPORTS / rid / "compliance_report.html"
return {"ready": cache_path.exists()}
"""Check compliance report status: ready, generating, or not started."""
rdir = REPORTS / rid
cache_path = rdir / "compliance_report.html"
marker = rdir / ".compliance_generating"
if cache_path.exists():
return {"ready": True, "generating": False}
if marker.exists():
return {"ready": False, "generating": True}
return {"ready": False, "generating": False}
@app.get("/api/reports/{rid}/compliance-report/download")
async def compliance_report_download(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
"""Download ZIP with compliance report PDF (Chromium headless) + CSV files."""
import zipfile, io, re as _re, subprocess, tempfile
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
rdir = REPORTS / rid
html_path = rdir / "compliance_report.html"
if not html_path.exists():
raise HTTPException(404, "Compliance report not generated yet")
with db() as c:
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
if not report: raise HTTPException(404)
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
tenancy = report["tenancy_name"] or "report"
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
file_map = {f["file_name"]: f["file_path"] for f in files}
# Prepare HTML for PDF: rewrite CSV links to relative paths
html = html_path.read_text(encoding="utf-8")
html = _re.sub(r'<button class="print-btn[^"]*"[^>]*>.*?</button>', '', html, flags=_re.DOTALL)
html = _re.sub(r'<script>.*?</script>', '', html, flags=_re.DOTALL)
for fname in file_map:
html = _re.sub(
r'href="[^"]*?/download[^"]*?"([^>]*>)\s*\[CSV\]\s*' + _re.escape(fname),
f'href="csv/{fname}"\\1[CSV] {fname}',
html
)
# Generate PDF via Chromium headless (same engine as browser print)
pdf_bytes = None
try:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_html = Path(tmpdir) / "report.html"
tmp_pdf = Path(tmpdir) / "report.pdf"
tmp_html.write_text(html, encoding="utf-8")
result = subprocess.run([
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
f"file://{tmp_html}"
], capture_output=True, timeout=120)
if tmp_pdf.exists():
pdf_bytes = tmp_pdf.read_bytes()
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
else:
log.error(f"Chromium PDF failed: {result.stderr.decode()[:500]}")
except Exception as e:
log.error(f"PDF generation failed: {e}", exc_info=True)
if not pdf_bytes:
raise HTTPException(500, "PDF generation failed")
# Build ZIP: PDF + CSVs
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"CIS_Compliance_Report_{safe_name}.pdf", pdf_bytes)
for fname, fpath in file_map.items():
p = Path(fpath)
if p.exists() and fname.lower().endswith('.csv'):
zf.write(str(p), f"csv/{fname}")
buf.seek(0)
from starlette.responses import Response
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.zip"'}
)
@app.get("/api/reports/{rid}/download")
async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):