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:
13
README.md
13
README.md
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-2.7-C74634?style=flat-square" alt="Version">
|
<img src="https://img.shields.io/badge/version-2.8-C74634?style=flat-square" alt="Version">
|
||||||
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
|
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
|
||||||
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
|
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
|
||||||
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
|
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
AI Agent — Infrastructure & Security Engineer is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution.
|
AI Agent — Infrastructure & Security Engineer is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution.
|
||||||
|
|
||||||
The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with a **dual frontend** (legacy SPA + React SPA at `/app/`), **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, **i18n** (pt/en), and **Chart.js** visualizations.
|
The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with a **React 19 SPA** (TypeScript, Vite), **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, **i18n** (pt/en), and **Chart.js** visualizations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -439,8 +439,6 @@ oci-cis-agent/
|
|||||||
│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator
|
│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator
|
||||||
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI
|
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI
|
||||||
│ └── requirements.txt # Dependencies
|
│ └── requirements.txt # Dependencies
|
||||||
├── frontend/
|
|
||||||
│ └── index.html # Legacy SPA with Oracle Dark Premium theme (~2800 lines)
|
|
||||||
├── frontend-react/
|
├── frontend-react/
|
||||||
│ ├── src/ # React 19 SPA (TypeScript, 42 source files, ~12500 lines)
|
│ ├── src/ # React 19 SPA (TypeScript, 42 source files, ~12500 lines)
|
||||||
│ │ ├── pages/ # 8 page components (Dashboard, Explorer, Reports, Chat, Terraform, etc.)
|
│ │ ├── pages/ # 8 page components (Dashboard, Explorer, Reports, Chat, Terraform, etc.)
|
||||||
@@ -448,12 +446,12 @@ oci-cis-agent/
|
|||||||
│ │ ├── stores/ # Zustand auth store
|
│ │ ├── stores/ # Zustand auth store
|
||||||
│ │ ├── hooks/ # Custom hooks (theme, i18n)
|
│ │ ├── hooks/ # Custom hooks (theme, i18n)
|
||||||
│ │ └── i18n/ # Internationalization (pt/en, 625 keys)
|
│ │ └── i18n/ # Internationalization (pt/en, 625 keys)
|
||||||
│ └── dist/ # Built SPA served at /app/
|
│ └── dist/ # Built SPA served at /
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── reportgenerator.js # n8n reference script (report layout)
|
│ ├── reportgenerator.js # n8n reference script (report layout)
|
||||||
│ └── *.pdf # Oracle CIS report PDF template
|
│ └── *.pdf # Oracle CIS report PDF template
|
||||||
├── nginx/
|
├── nginx/
|
||||||
│ └── default.conf # Reverse proxy (legacy / + React /app/ + API /api/)
|
│ └── default.conf # Reverse proxy (React SPA + API /api/)
|
||||||
├── docker-compose.yml # Orchestration
|
├── docker-compose.yml # Orchestration
|
||||||
├── logo.svg # Project logo (Oracle AI Robot)
|
├── logo.svg # Project logo (Oracle AI Robot)
|
||||||
├── .env.example # Environment template
|
├── .env.example # Environment template
|
||||||
@@ -711,7 +709,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
|
|||||||
| Component | Technology |
|
| Component | Technology |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| Backend | Python 3.12, FastAPI 0.115, Uvicorn |
|
| Backend | Python 3.12, FastAPI 0.115, Uvicorn |
|
||||||
| Frontend | React 19 SPA (Vite + TypeScript) at `/app/` + legacy Vanilla JS SPA, Oracle Dark Premium theme, Chart.js, i18n (pt/en) |
|
| Frontend | React 19 SPA (Vite + TypeScript), Oracle Dark Premium theme, Chart.js, i18n (pt/en), rehype-highlight |
|
||||||
| Auth | JWT + TOTP MFA + RBAC |
|
| Auth | JWT + TOTP MFA + RBAC |
|
||||||
| Database | SQLite (WAL mode) |
|
| Database | SQLite (WAL mode) |
|
||||||
| OCI SDK | `oci` 2.133.0, `oci-cli` |
|
| OCI SDK | `oci` 2.133.0, `oci-cli` |
|
||||||
@@ -728,6 +726,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
|
|||||||
|
|
||||||
| Version | Date | Changes |
|
| Version | Date | Changes |
|
||||||
|---------|------|---------|
|
|---------|------|---------|
|
||||||
|
| **v2.8** | 2026-03 | **Legacy frontend removed**: React SPA now served at root `/` (no more `/app/` prefix), legacy vanilla JS SPA removed. **Compliance Report ZIP download**: Chromium headless PDF generation (identical to browser print) + CSV findings bundled in ZIP. **Compliance generation state persisted server-side**: `.compliance_generating` marker file ensures generation status survives page navigation — spinner auto-resumes on return. **RAG remediation enriched**: extracts Description, Rationale, Audit, and Remediation sections from CIS vector chunks, combines up to 3 matched chunks. **Chat markdown styling**: full markdown rendering with syntax highlighting (Catppuccin Mocha theme via rehype-highlight), styled headings, lists, tables, blockquotes, inline/block code. **Default model auto-select**: Chat Agent auto-selects first GenAI config on page load. **Explorer silent polling**: no more flickering during start/stop resource actions. |
|
||||||
| **v2.7** | 2026-03 | **LAD A-Team CIS Compliance Report**: professional Oracle-format compliance report with cover page, TOC, Security Overview (7 pillars), CIS Assessment Summary, detailed findings with compliance % bars, RAG-powered remediation from ADB vector store (`CISRECOM` table with strict recommendation number filtering), affected resources CSV download links per non-compliant finding (JWT auth via query param). **React SPA**: 16-page React 19 frontend at `/app/` (TypeScript, Vite, Zustand, 42 source files), dual-served alongside legacy SPA via nginx. **i18n**: full internationalization with 625 keys (pt/en), language switcher persisted in localStorage. **Report management**: delete reports endpoint, embed individual report files (CSV/TXT/JSON/PDF) into vector store. **Terraform ZIP download**: replaced individual file downloads with client-side ZIP generation (pure JS, no external lib). **Explorer UX**: silent polling during start/stop actions eliminates flickering (resources update in-place without clearing the list). **Report sections collapsed by default**: HTML report and compliance report iframes start minimized. |
|
| **v2.7** | 2026-03 | **LAD A-Team CIS Compliance Report**: professional Oracle-format compliance report with cover page, TOC, Security Overview (7 pillars), CIS Assessment Summary, detailed findings with compliance % bars, RAG-powered remediation from ADB vector store (`CISRECOM` table with strict recommendation number filtering), affected resources CSV download links per non-compliant finding (JWT auth via query param). **React SPA**: 16-page React 19 frontend at `/app/` (TypeScript, Vite, Zustand, 42 source files), dual-served alongside legacy SPA via nginx. **i18n**: full internationalization with 625 keys (pt/en), language switcher persisted in localStorage. **Report management**: delete reports endpoint, embed individual report files (CSV/TXT/JSON/PDF) into vector store. **Terraform ZIP download**: replaced individual file downloads with client-side ZIP generation (pure JS, no external lib). **Explorer UX**: silent polling during start/stop actions eliminates flickering (resources update in-place without clearing the list). **Report sections collapsed by default**: HTML report and compliance report iframes start minimized. |
|
||||||
| **v2.6** | 2026-03 | **Rename**: app renamed to "AI Agent — Infrastructure & Security Engineer". **Session Token Auth**: OCI session token authentication support (type selector API Key/Session Token, conditional form fields, collapsible credential form, type tags in credentials table, `SecurityTokenSigner` backend). **Terraform Workspaces submenu**: sidebar sub-tab listing all workspaces grouped by date with accordion UI, filtered by existing chat sessions (INNER JOIN), actions (plan/apply/destroy/delete/open). **Retry on failure**: "Reenviar" button on failed messages across Chat Agent, Terraform Agent, and Prompt Generator. **Terraform fixes**: `f2` undefined variable in dedup logic, `oci_core_services` false positive validation (data sources excluded), `S.tfCode` reconstruction from `S.tfFiles` for Re-plan, auto-fix button visible without pre-selected model, DRG attachment rules in system prompt. **Resilience**: stuck workspace recovery on container restart (planning/applying/destroying → reset + lock file cleanup). |
|
| **v2.6** | 2026-03 | **Rename**: app renamed to "AI Agent — Infrastructure & Security Engineer". **Session Token Auth**: OCI session token authentication support (type selector API Key/Session Token, conditional form fields, collapsible credential form, type tags in credentials table, `SecurityTokenSigner` backend). **Terraform Workspaces submenu**: sidebar sub-tab listing all workspaces grouped by date with accordion UI, filtered by existing chat sessions (INNER JOIN), actions (plan/apply/destroy/delete/open). **Retry on failure**: "Reenviar" button on failed messages across Chat Agent, Terraform Agent, and Prompt Generator. **Terraform fixes**: `f2` undefined variable in dedup logic, `oci_core_services` false positive validation (data sources excluded), `S.tfCode` reconstruction from `S.tfFiles` for Re-plan, auto-fix button visible without pre-selected model, DRG attachment rules in system prompt. **Resilience**: stuck workspace recovery on container restart (planning/applying/destroying → reset + lock file cleanup). |
|
||||||
| **v2.5** | 2026-03 | **Explorer Expansion**: KPI stats bar with per-category resource counts (cached in SQLite `explorer_counts_cache`, background refresh on compartment access), enhanced views for VCNs/Subnets/Load Balancers/Buckets with expandable cards, start/stop for DB Systems + MySQL + Container Instances (5 resource types total), dead state filtering (`TERMINATED`/`DELETED`/`DELETING` hidden across all 36 endpoints), wider compartment tree (280px), IAM excluded from compartment totals. **Prompt Generator Intelligence**: same knowledge pipeline as Terraform Agent — auto-detects resource types from user message, fetches official docs (Example Usage + Arguments) from GitHub cached in SQLite, uses compact categorized resource reference. **Network Firewall fix**: `NetworkFirewallPolicySummaryCollection` not iterable — added `.items` accessor for collection objects. |
|
| **v2.5** | 2026-03 | **Explorer Expansion**: KPI stats bar with per-category resource counts (cached in SQLite `explorer_counts_cache`, background refresh on compartment access), enhanced views for VCNs/Subnets/Load Balancers/Buckets with expandable cards, start/stop for DB Systems + MySQL + Container Instances (5 resource types total), dead state filtering (`TERMINATED`/`DELETED`/`DELETING` hidden across all 36 endpoints), wider compartment tree (280px), IAM excluded from compartment totals. **Prompt Generator Intelligence**: same knowledge pipeline as Terraform Agent — auto-detects resource types from user message, fetches official docs (Example Usage + Arguments) from GitHub cached in SQLite, uses compact categorized resource reference. **Network Firewall fix**: `NetworkFirewallPolicySummaryCollection` not iterable — added `.items` accessor for collection objects. |
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Install system deps + Terraform CLI
|
# Install system deps + Terraform CLI
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl gcc libffi-dev unzip && \
|
curl gcc libffi-dev unzip \
|
||||||
|
chromium && \
|
||||||
ARCH=$(dpkg --print-architecture) && \
|
ARCH=$(dpkg --print-architecture) && \
|
||||||
curl -fsSL "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" -o /tmp/tf.zip && \
|
curl -fsSL "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" -o /tmp/tf.zip && \
|
||||||
unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \
|
unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \
|
||||||
|
|||||||
151
backend/app.py
151
backend/app.py
@@ -4140,6 +4140,16 @@ def _extract_remediation_section(text: str) -> str:
|
|||||||
return _extract_section(text, "Remediation")
|
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:
|
def _extract_description_section(text: str) -> str:
|
||||||
"""Extract the Description section (what this CIS finding is about)."""
|
"""Extract the Description section (what this CIS finding is about)."""
|
||||||
return _extract_section(text, "Description")
|
return _extract_section(text, "Description")
|
||||||
@@ -4268,11 +4278,28 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
|
|||||||
if matched:
|
if matched:
|
||||||
best = matched[0]
|
best = matched[0]
|
||||||
desc = _extract_description_section(best)
|
desc = _extract_description_section(best)
|
||||||
|
rationale = _extract_rationale_section(best)
|
||||||
rem = _extract_remediation_section(best)
|
rem = _extract_remediation_section(best)
|
||||||
# Try second chunk if first has no remediation
|
audit = _extract_audit_section(best)
|
||||||
if not rem and len(matched) > 1:
|
# Try additional chunks for fuller content
|
||||||
rem = _extract_remediation_section(matched[1])
|
for extra in matched[1:3]:
|
||||||
return {"description": desc, "remediation": rem}
|
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:
|
except Exception as e:
|
||||||
log.warning(f"CIS remediation search failed for {adb_cfg.get('config_name','?')}: {e}")
|
log.warning(f"CIS remediation search failed for {adb_cfg.get('config_name','?')}: {e}")
|
||||||
return {"description": "", "remediation": ""}
|
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()
|
csv_remediation = (r.get("Remediation", "") or "").strip()
|
||||||
rag_data = r.get("__rag_remediation", {})
|
rag_data = r.get("__rag_remediation", {})
|
||||||
if isinstance(rag_data, str):
|
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_desc = (rag_data.get("description", "") or "").strip()
|
||||||
|
rag_rationale = (rag_data.get("rationale", "") or "").strip()
|
||||||
rag_rem = (rag_data.get("remediation", "") or "").strip()
|
rag_rem = (rag_data.get("remediation", "") or "").strip()
|
||||||
|
|
||||||
# Status line
|
# 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)"
|
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>'
|
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 = ""
|
description_html = ""
|
||||||
if not is_compliant and rag_desc:
|
if not is_compliant and rag_desc:
|
||||||
description_html = f'<div class="finding-desc"><h4>Description:</h4><p>{esc(rag_desc)}</p></div>'
|
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 = ""
|
remediation_html = ""
|
||||||
if not is_compliant:
|
if not is_compliant:
|
||||||
rem_text = rag_rem or csv_remediation
|
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}
|
{result_html}
|
||||||
{findings_file_html}
|
{findings_file_html}
|
||||||
{description_html}
|
{description_html}
|
||||||
|
{rationale_html}
|
||||||
{remediation_html}
|
{remediation_html}
|
||||||
</div>'''
|
</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 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; }}
|
.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 */
|
||||||
.remediation {{ margin:10px 0 0; }}
|
.remediation {{ margin:10px 0 0; }}
|
||||||
.remediation h4 {{ font-size:11.5px; margin:0 0 6px; color:var(--oracle-red); font-weight:700; }}
|
.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():
|
if not csv_path.exists():
|
||||||
raise HTTPException(404, "cis_summary_report.csv not found")
|
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"
|
cache_path = rdir / "compliance_report.html"
|
||||||
if cache_path.exists():
|
if cache_path.exists():
|
||||||
cache_path.unlink()
|
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}")
|
log.info(f"Compliance report generation completed for {rid}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True)
|
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)
|
_chat_executor.submit(_bg_generate)
|
||||||
return {"status": "generating", "has_rag": has_adb}
|
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")
|
@app.get("/api/reports/{rid}/compliance-report/status")
|
||||||
async def compliance_report_status(rid: str):
|
async def compliance_report_status(rid: str):
|
||||||
"""Check if the compliance report exists."""
|
"""Check compliance report status: ready, generating, or not started."""
|
||||||
cache_path = REPORTS / rid / "compliance_report.html"
|
rdir = REPORTS / rid
|
||||||
return {"ready": cache_path.exists()}
|
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")
|
@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))):
|
async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ services:
|
|||||||
container_name: oci-agent-frontend
|
container_name: oci-agent-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
|
||||||
- ./frontend-react/dist:/usr/share/nginx/html/app:ro
|
- ./frontend-react/dist:/usr/share/nginx/html/app:ro
|
||||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
# Note: nginx root points to /usr/share/nginx/html/app (React dist)
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-8080}:80"
|
- "${PORT:-8080}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
115
frontend-react/dist/assets/index-BCYp2Z2l.js
vendored
115
frontend-react/dist/assets/index-BCYp2Z2l.js
vendored
File diff suppressed because one or more lines are too long
2
frontend-react/dist/assets/index-Ca4AYldT.css
vendored
Normal file
2
frontend-react/dist/assets/index-Ca4AYldT.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
115
frontend-react/dist/assets/index-Dtsz7Biu.js
vendored
Normal file
115
frontend-react/dist/assets/index-Dtsz7Biu.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend-react/dist/index.html
vendored
4
frontend-react/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI Agent - Infrastructure & Security Engineer</title>
|
<title>AI Agent - Infrastructure & Security Engineer</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/app/assets/index-BCYp2Z2l.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dtsz7Biu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/app/assets/index-D9WRGO1k.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Ca4AYldT.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ client.interceptors.response.use(
|
|||||||
(err) => {
|
(err) => {
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
localStorage.removeItem('t');
|
localStorage.removeItem('t');
|
||||||
window.location.href = '/app/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
const detail = err.response?.data?.detail || err.response?.data?.message || err.message || 'Erro';
|
const detail = err.response?.data?.detail || err.response?.data?.message || err.message || 'Erro';
|
||||||
return Promise.reject(new Error(detail));
|
return Promise.reject(new Error(detail));
|
||||||
|
|||||||
@@ -109,7 +109,12 @@ export const reportsApi = {
|
|||||||
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
|
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
|
||||||
|
|
||||||
complianceReportStatus: (rid: string) =>
|
complianceReportStatus: (rid: string) =>
|
||||||
client.get(`/reports/${rid}/compliance-report/status`) as unknown as Promise<{ ready: boolean }>,
|
client.get(`/reports/${rid}/compliance-report/status`) as unknown as Promise<{ ready: boolean; generating: boolean }>,
|
||||||
|
|
||||||
|
complianceReportZipUrl: (rid: string) => {
|
||||||
|
const token = localStorage.getItem('t') || '';
|
||||||
|
return `/api/reports/${rid}/compliance-report/download?token=${encodeURIComponent(token)}`;
|
||||||
|
},
|
||||||
|
|
||||||
files: (rid: string) =>
|
files: (rid: string) =>
|
||||||
client.get(`/reports/${rid}/files`) as unknown as Promise<ReportFile[]>,
|
client.get(`/reports/${rid}/files`) as unknown as Promise<ReportFile[]>,
|
||||||
|
|||||||
@@ -409,23 +409,153 @@ nav a:hover { background: var(--bg3); color: var(--t1) !important; }
|
|||||||
border-bottom: 1px solid var(--bg3);
|
border-bottom: 1px solid var(--bg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent text overflow on resize */
|
/* ── Chat Markdown Styling ───────────────────────────────────── */
|
||||||
.prose pre, .chat-markdown pre {
|
.chat-markdown {
|
||||||
overflow-x: auto;
|
font-size: .82rem;
|
||||||
max-width: 100%;
|
line-height: 1.7;
|
||||||
|
color: var(--t1);
|
||||||
}
|
}
|
||||||
.prose table, .chat-markdown table {
|
.chat-markdown p {
|
||||||
overflow-x: auto;
|
margin: 0 0 .65em;
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.prose code, .chat-markdown code {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.prose p, .chat-markdown p {
|
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
.chat-markdown p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.chat-markdown h1, .chat-markdown h2, .chat-markdown h3,
|
||||||
|
.chat-markdown h4, .chat-markdown h5, .chat-markdown h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--t1);
|
||||||
|
margin: 1em 0 .4em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.chat-markdown h1 { font-size: 1.15em; }
|
||||||
|
.chat-markdown h2 { font-size: 1.05em; }
|
||||||
|
.chat-markdown h3 { font-size: .95em; }
|
||||||
|
.chat-markdown h4, .chat-markdown h5, .chat-markdown h6 { font-size: .88em; }
|
||||||
|
.chat-markdown > h1:first-child,
|
||||||
|
.chat-markdown > h2:first-child,
|
||||||
|
.chat-markdown > h3:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.chat-markdown ul, .chat-markdown ol {
|
||||||
|
margin: .4em 0 .65em;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
.chat-markdown ul { list-style: disc; }
|
||||||
|
.chat-markdown ol { list-style: decimal; }
|
||||||
|
.chat-markdown li { margin: .2em 0; }
|
||||||
|
.chat-markdown li > ul, .chat-markdown li > ol { margin: .15em 0; }
|
||||||
|
.chat-markdown li > p { margin: .15em 0; }
|
||||||
|
|
||||||
|
/* Inline code */
|
||||||
|
.chat-markdown code:not(pre code) {
|
||||||
|
background: color-mix(in srgb, var(--ac) 10%, transparent);
|
||||||
|
color: var(--ac);
|
||||||
|
padding: .12em .35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .88em;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.chat-markdown pre {
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .85em 1em;
|
||||||
|
margin: .5em 0 .75em;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: .78rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--bd) 50%, transparent);
|
||||||
|
}
|
||||||
|
.chat-markdown pre code {
|
||||||
|
background: none !important;
|
||||||
|
color: inherit !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
word-break: normal;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.chat-markdown table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: .5em 0 .75em;
|
||||||
|
font-size: .8rem;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.chat-markdown th, .chat-markdown td {
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
padding: .4em .7em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.chat-markdown th {
|
||||||
|
background: color-mix(in srgb, var(--ac) 8%, transparent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .78rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chat-markdown tr:nth-child(even) {
|
||||||
|
background: color-mix(in srgb, var(--bd) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
|
.chat-markdown blockquote {
|
||||||
|
border-left: 3px solid var(--ac);
|
||||||
|
margin: .5em 0 .75em;
|
||||||
|
padding: .4em .8em;
|
||||||
|
background: color-mix(in srgb, var(--ac) 5%, transparent);
|
||||||
|
color: var(--t2);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
.chat-markdown blockquote p { margin: 0; }
|
||||||
|
|
||||||
|
/* Horizontal rule */
|
||||||
|
.chat-markdown hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--bd);
|
||||||
|
margin: .8em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.chat-markdown a {
|
||||||
|
color: var(--ac);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strong / emphasis */
|
||||||
|
.chat-markdown strong { font-weight: 700; color: var(--t1); }
|
||||||
|
.chat-markdown em { font-style: italic; }
|
||||||
|
|
||||||
|
/* ── Syntax Highlighting (Catppuccin Mocha) ─────────────────── */
|
||||||
|
.hljs-keyword, .hljs-selector-tag { color: #cba6f7; }
|
||||||
|
.hljs-string, .hljs-attr { color: #a6e3a1; }
|
||||||
|
.hljs-number, .hljs-literal { color: #fab387; }
|
||||||
|
.hljs-comment { color: #6c7086; font-style: italic; }
|
||||||
|
.hljs-built_in, .hljs-builtin-name { color: #89dceb; }
|
||||||
|
.hljs-function .hljs-title, .hljs-title.function_ { color: #89b4fa; }
|
||||||
|
.hljs-type, .hljs-title.class_ { color: #f9e2af; }
|
||||||
|
.hljs-variable, .hljs-template-variable { color: #f38ba8; }
|
||||||
|
.hljs-meta { color: #fab387; }
|
||||||
|
.hljs-punctuation { color: #bac2de; }
|
||||||
|
.hljs-tag { color: #89b4fa; }
|
||||||
|
.hljs-name { color: #89b4fa; }
|
||||||
|
.hljs-attribute { color: #f9e2af; }
|
||||||
|
|
||||||
|
/* ── Prose overflow (legacy compat) ─────────────────────────── */
|
||||||
|
.prose pre { overflow-x: auto; max-width: 100%; }
|
||||||
|
.prose table { overflow-x: auto; display: block; max-width: 100%; }
|
||||||
|
.prose code { word-break: break-all; }
|
||||||
|
.prose p { overflow-wrap: break-word; word-break: break-word; }
|
||||||
|
|
||||||
/* Tooltip-like labels */
|
/* Tooltip-like labels */
|
||||||
.field-label {
|
.field-label {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import App from './App.tsx';
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter basename="/app">
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
import { useAppStore } from '@/stores/app';
|
import { useAppStore } from '@/stores/app';
|
||||||
import { useI18n } from '@/i18n';
|
import { useI18n } from '@/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -101,7 +102,12 @@ export default function ChatPage() {
|
|||||||
const [messages, setMessages] = useState<UIMessage[]>([]);
|
const [messages, setMessages] = useState<UIMessage[]>([]);
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
const [model, setModel] = useState('');
|
const [model, setModel] = useState(() => {
|
||||||
|
// Auto-select first genai config as default model
|
||||||
|
const store = useAppStore.getState();
|
||||||
|
if (store.genaiCfg.length > 0) return `cfg:${store.genaiCfg[0].id}`;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
const [ociConfig, setOciConfig] = useState('');
|
const [ociConfig, setOciConfig] = useState('');
|
||||||
const [files, setFiles] = useState<AttachedFile[]>([]);
|
const [files, setFiles] = useState<AttachedFile[]>([]);
|
||||||
const [chatParams, setChatParams] = useState<ChatParams>({ ...DEFAULT_PARAMS });
|
const [chatParams, setChatParams] = useState<ChatParams>({ ...DEFAULT_PARAMS });
|
||||||
@@ -145,6 +151,13 @@ export default function ChatPage() {
|
|||||||
[mcpSvr],
|
[mcpSvr],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-select default model when genaiCfg loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model && genaiCfg.length > 0) {
|
||||||
|
setModel(`cfg:${genaiCfg[0].id}`);
|
||||||
|
}
|
||||||
|
}, [genaiCfg]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const hasRag = useMemo(
|
const hasRag = useMemo(
|
||||||
() => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0),
|
() => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0),
|
||||||
[adbCfg, genaiCfg, ociCfg],
|
[adbCfg, genaiCfg, ociCfg],
|
||||||
@@ -789,7 +802,7 @@ export default function ChatPage() {
|
|||||||
<div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
|
<div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="chat-markdown prose prose-sm max-w-none [&_pre]:overflow-x-auto [&_table]:overflow-x-auto" style={{ overflowWrap: 'break-word', wordBreak: 'break-word' }}>
|
<div className="chat-markdown prose prose-sm max-w-none [&_pre]:overflow-x-auto [&_table]:overflow-x-auto" style={{ overflowWrap: 'break-word', wordBreak: 'break-word' }}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{msg.content}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
import { useAppStore, type ModelInfo } from '@/stores/app';
|
import { useAppStore, type ModelInfo } from '@/stores/app';
|
||||||
import { terraformApi, type ChatSession } from '@/api/endpoints/terraform';
|
import { terraformApi, type ChatSession } from '@/api/endpoints/terraform';
|
||||||
import { useI18n } from '@/i18n';
|
import { useI18n } from '@/i18n';
|
||||||
@@ -459,7 +460,7 @@ export default function PromptGeneratorPage() {
|
|||||||
['--tw-prose-headings' as string]: 'var(--t1)',
|
['--tw-prose-headings' as string]: 'var(--t1)',
|
||||||
['--tw-prose-code' as string]: 'var(--pp)',
|
['--tw-prose-code' as string]: 'var(--pp)',
|
||||||
}}>
|
}}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}
|
||||||
components={{
|
components={{
|
||||||
pre: ({ children }) => (
|
pre: ({ children }) => (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
|
|||||||
@@ -577,7 +577,6 @@ export default function ReportsPage() {
|
|||||||
const [showCompliance, setShowCompliance] = useState(false);
|
const [showCompliance, setShowCompliance] = useState(false);
|
||||||
const [complianceReady, setComplianceReady] = useState(false);
|
const [complianceReady, setComplianceReady] = useState(false);
|
||||||
const [complianceGenerating, setComplianceGenerating] = useState(false);
|
const [complianceGenerating, setComplianceGenerating] = useState(false);
|
||||||
const compliancePollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
/* ── Error ── */
|
/* ── Error ── */
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -625,12 +624,17 @@ export default function ReportsPage() {
|
|||||||
if (running && !trackingId) {
|
if (running && !trackingId) {
|
||||||
setTrackingId(running.id);
|
setTrackingId(running.id);
|
||||||
}
|
}
|
||||||
|
// Auto-select most recent completed report if none selected
|
||||||
|
if (!selectedRid && data.length > 0) {
|
||||||
|
const completed = data.find((r) => r.status === 'completed');
|
||||||
|
if (completed) setSelectedRid(completed.id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('rpt.errorLoadReports'));
|
setError(err instanceof Error ? err.message : t('rpt.errorLoadReports'));
|
||||||
} finally {
|
} finally {
|
||||||
setReportsLoading(false);
|
setReportsLoading(false);
|
||||||
}
|
}
|
||||||
}, [trackingId]);
|
}, [trackingId, selectedRid]);
|
||||||
|
|
||||||
useEffect(() => { loadReports(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { loadReports(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -672,14 +676,14 @@ export default function ReportsPage() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
reportsApi.summary(selectedRid),
|
reportsApi.summary(selectedRid),
|
||||||
reportsApi.files(selectedRid),
|
reportsApi.files(selectedRid),
|
||||||
reportsApi.complianceReportStatus(selectedRid).catch(() => ({ ready: false })),
|
reportsApi.complianceReportStatus(selectedRid).catch(() => ({ ready: false, generating: false } as { ready: boolean; generating: boolean })),
|
||||||
])
|
])
|
||||||
.then(([summaryData, filesData, compStatus]) => {
|
.then(([summaryData, filesData, compStatus]) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setKpi(summaryData);
|
setKpi(summaryData);
|
||||||
setFiles(filesData);
|
setFiles(filesData);
|
||||||
setComplianceReady(compStatus.ready);
|
setComplianceReady(!!compStatus.ready);
|
||||||
setComplianceGenerating(false);
|
setComplianceGenerating(!!compStatus.generating);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -695,43 +699,34 @@ export default function ReportsPage() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [selectedRid]);
|
}, [selectedRid]);
|
||||||
|
|
||||||
/* ── Compliance report polling (useEffect-based, survives re-renders + tab throttle) ── */
|
/* ── Compliance report polling (uses same usePolling as CIS report) ── */
|
||||||
useEffect(() => {
|
usePolling(
|
||||||
if (!complianceGenerating || !selectedRid) return;
|
async () => {
|
||||||
let active = true;
|
if (!complianceGenerating || !selectedRid) return false;
|
||||||
const check = async () => {
|
|
||||||
try {
|
try {
|
||||||
const s = await reportsApi.complianceReportStatus(selectedRid);
|
const s = await reportsApi.complianceReportStatus(selectedRid);
|
||||||
if (s.ready && active) {
|
if (s.ready) {
|
||||||
setComplianceReady(true);
|
setComplianceReady(true);
|
||||||
setComplianceGenerating(false);
|
setComplianceGenerating(false);
|
||||||
setShowCompliance(true);
|
setShowCompliance(true);
|
||||||
|
return true; // stop polling
|
||||||
}
|
}
|
||||||
} catch { /* keep polling */ }
|
} catch { /* keep polling */ }
|
||||||
};
|
return false;
|
||||||
check();
|
},
|
||||||
const id = setInterval(check, 3000);
|
3000,
|
||||||
compliancePollRef.current = id;
|
complianceGenerating && !!selectedRid,
|
||||||
const timeout = setTimeout(() => { clearInterval(id); if (active) setComplianceGenerating(false); }, 300000);
|
);
|
||||||
return () => { active = false; clearInterval(id); clearTimeout(timeout); compliancePollRef.current = null; };
|
|
||||||
}, [complianceGenerating, selectedRid]);
|
|
||||||
|
|
||||||
/* ── Re-check compliance status when section is expanded ── */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showCompliance || !selectedRid || complianceReady || complianceGenerating) return;
|
|
||||||
reportsApi.complianceReportStatus(selectedRid)
|
|
||||||
.then((s) => { if (s.ready) setComplianceReady(true); })
|
|
||||||
.catch(() => {});
|
|
||||||
}, [showCompliance, selectedRid, complianceReady, complianceGenerating]);
|
|
||||||
|
|
||||||
const startComplianceGeneration = useCallback(async () => {
|
const startComplianceGeneration = useCallback(async () => {
|
||||||
if (!selectedRid) return;
|
if (!selectedRid || complianceGenerating) return;
|
||||||
setComplianceGenerating(true);
|
|
||||||
setComplianceReady(false);
|
setComplianceReady(false);
|
||||||
|
setComplianceGenerating(true);
|
||||||
try {
|
try {
|
||||||
await reportsApi.generateComplianceReport(selectedRid);
|
await reportsApi.generateComplianceReport(selectedRid);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [selectedRid]);
|
// polling is already running via usePolling above
|
||||||
|
}, [selectedRid, complianceGenerating]);
|
||||||
|
|
||||||
/* ── Handlers ── */
|
/* ── Handlers ── */
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
@@ -1337,7 +1332,14 @@ export default function ReportsPage() {
|
|||||||
style={{ height: 700, background: '#fff' }}
|
style={{ height: 700, background: '#fff' }}
|
||||||
title="LAD A-Team CIS Compliance Report"
|
title="LAD A-Team CIS Compliance Report"
|
||||||
/>
|
/>
|
||||||
<div className="px-5 py-2 flex justify-end" style={{ borderTop: '1px solid var(--bd)' }}>
|
<div className="px-5 py-2 flex justify-end gap-2" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||||
|
<a
|
||||||
|
href={reportsApi.complianceReportZipUrl(selectedRid)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors no-underline"
|
||||||
|
style={{ background: 'var(--ac)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
<Download size={12} /> Download ZIP
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={startComplianceGeneration}
|
onClick={startComplianceGeneration}
|
||||||
disabled={complianceGenerating}
|
disabled={complianceGenerating}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/app/',
|
base: '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -5,18 +5,9 @@ server {
|
|||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
|
|
||||||
# New React app at /app/
|
# React SPA at root
|
||||||
location ^~ /app/ {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
# Files are in /usr/share/nginx/html/app/ (mapped from dist/)
|
|
||||||
try_files $uri $uri/ /app/index.html;
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Legacy SPA at /
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html/app;
|
||||||
index index.html;
|
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user