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

@@ -9,7 +9,7 @@
</p>
<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/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
<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.
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
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform CLI
│ └── requirements.txt # Dependencies
├── frontend/
│ └── index.html # Legacy SPA with Oracle Dark Premium theme (~2800 lines)
├── frontend-react/
│ ├── src/ # React 19 SPA (TypeScript, 42 source files, ~12500 lines)
│ │ ├── pages/ # 8 page components (Dashboard, Explorer, Reports, Chat, Terraform, etc.)
@@ -448,12 +446,12 @@ oci-cis-agent/
│ │ ├── stores/ # Zustand auth store
│ │ ├── hooks/ # Custom hooks (theme, i18n)
│ │ └── i18n/ # Internationalization (pt/en, 625 keys)
│ └── dist/ # Built SPA served at /app/
│ └── dist/ # Built SPA served at /
├── scripts/
│ ├── reportgenerator.js # n8n reference script (report layout)
│ └── *.pdf # Oracle CIS report PDF template
├── nginx/
│ └── default.conf # Reverse proxy (legacy / + React /app/ + API /api/)
│ └── default.conf # Reverse proxy (React SPA + API /api/)
├── docker-compose.yml # Orchestration
├── logo.svg # Project logo (Oracle AI Robot)
├── .env.example # Environment template
@@ -711,7 +709,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Component | Technology |
|-----------|-----------|
| 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 |
| Database | SQLite (WAL mode) |
| 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 |
|---------|------|---------|
| **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.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. |

View File

@@ -4,7 +4,8 @@ WORKDIR /app
# Install system deps + Terraform CLI
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) && \
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 && \

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))):

View File

@@ -25,9 +25,9 @@ services:
container_name: oci-agent-frontend
restart: unless-stopped
volumes:
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
- ./frontend-react/dist:/usr/share/nginx/html/app:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# Note: nginx root points to /usr/share/nginx/html/app (React dist)
ports:
- "${PORT:-8080}:80"
depends_on:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
<script type="module" crossorigin src="/app/assets/index-BCYp2Z2l.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-D9WRGO1k.css">
<script type="module" crossorigin src="/assets/index-Dtsz7Biu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ca4AYldT.css">
</head>
<body>
<div id="root"></div>

View File

@@ -20,7 +20,7 @@ client.interceptors.response.use(
(err) => {
if (err.response?.status === 401) {
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';
return Promise.reject(new Error(detail));

View File

@@ -109,7 +109,12 @@ export const reportsApi = {
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
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) =>
client.get(`/reports/${rid}/files`) as unknown as Promise<ReportFile[]>,

View File

@@ -409,23 +409,153 @@ nav a:hover { background: var(--bg3); color: var(--t1) !important; }
border-bottom: 1px solid var(--bg3);
}
/* Prevent text overflow on resize */
.prose pre, .chat-markdown pre {
overflow-x: auto;
max-width: 100%;
/* ── Chat Markdown Styling ───────────────────────────────────── */
.chat-markdown {
font-size: .82rem;
line-height: 1.7;
color: var(--t1);
}
.prose table, .chat-markdown table {
overflow-x: auto;
display: block;
max-width: 100%;
}
.prose code, .chat-markdown code {
word-break: break-all;
}
.prose p, .chat-markdown p {
.chat-markdown p {
margin: 0 0 .65em;
overflow-wrap: 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 */
.field-label {

View File

@@ -6,7 +6,7 @@ import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/app">
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,

View File

@@ -5,6 +5,7 @@ import {
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { useAppStore } from '@/stores/app';
import { useI18n } from '@/i18n';
import {
@@ -101,7 +102,12 @@ export default function ChatPage() {
const [messages, setMessages] = useState<UIMessage[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
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 [files, setFiles] = useState<AttachedFile[]>([]);
const [chatParams, setChatParams] = useState<ChatParams>({ ...DEFAULT_PARAMS });
@@ -145,6 +151,13 @@ export default function ChatPage() {
[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(
() => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0),
[adbCfg, genaiCfg, ociCfg],
@@ -789,7 +802,7 @@ export default function ChatPage() {
<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' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{msg.content}</ReactMarkdown>
</div>
)}
</div>

View File

@@ -5,6 +5,7 @@ import {
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { useAppStore, type ModelInfo } from '@/stores/app';
import { terraformApi, type ChatSession } from '@/api/endpoints/terraform';
import { useI18n } from '@/i18n';
@@ -459,7 +460,7 @@ export default function PromptGeneratorPage() {
['--tw-prose-headings' as string]: 'var(--t1)',
['--tw-prose-code' as string]: 'var(--pp)',
}}>
<ReactMarkdown remarkPlugins={[remarkGfm]}
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}
components={{
pre: ({ children }) => (
<div className="relative group">

View File

@@ -577,7 +577,6 @@ export default function ReportsPage() {
const [showCompliance, setShowCompliance] = useState(false);
const [complianceReady, setComplianceReady] = useState(false);
const [complianceGenerating, setComplianceGenerating] = useState(false);
const compliancePollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ── Error ── */
const [error, setError] = useState('');
@@ -625,12 +624,17 @@ export default function ReportsPage() {
if (running && !trackingId) {
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) {
setError(err instanceof Error ? err.message : t('rpt.errorLoadReports'));
} finally {
setReportsLoading(false);
}
}, [trackingId]);
}, [trackingId, selectedRid]);
useEffect(() => { loadReports(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -672,14 +676,14 @@ export default function ReportsPage() {
Promise.all([
reportsApi.summary(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]) => {
if (!cancelled) {
setKpi(summaryData);
setFiles(filesData);
setComplianceReady(compStatus.ready);
setComplianceGenerating(false);
setComplianceReady(!!compStatus.ready);
setComplianceGenerating(!!compStatus.generating);
}
})
.catch(() => {
@@ -695,43 +699,34 @@ export default function ReportsPage() {
return () => { cancelled = true; };
}, [selectedRid]);
/* ── Compliance report polling (useEffect-based, survives re-renders + tab throttle) ── */
useEffect(() => {
if (!complianceGenerating || !selectedRid) return;
let active = true;
const check = async () => {
/* ── Compliance report polling (uses same usePolling as CIS report) ── */
usePolling(
async () => {
if (!complianceGenerating || !selectedRid) return false;
try {
const s = await reportsApi.complianceReportStatus(selectedRid);
if (s.ready && active) {
if (s.ready) {
setComplianceReady(true);
setComplianceGenerating(false);
setShowCompliance(true);
return true; // stop polling
}
} catch { /* keep polling */ }
};
check();
const id = setInterval(check, 3000);
compliancePollRef.current = id;
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]);
return false;
},
3000,
complianceGenerating && !!selectedRid,
);
const startComplianceGeneration = useCallback(async () => {
if (!selectedRid) return;
setComplianceGenerating(true);
if (!selectedRid || complianceGenerating) return;
setComplianceReady(false);
setComplianceGenerating(true);
try {
await reportsApi.generateComplianceReport(selectedRid);
} catch { /* ignore */ }
}, [selectedRid]);
// polling is already running via usePolling above
}, [selectedRid, complianceGenerating]);
/* ── Handlers ── */
const handleRun = async () => {
@@ -1337,7 +1332,14 @@ export default function ReportsPage() {
style={{ height: 700, background: '#fff' }}
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
onClick={startComplianceGeneration}
disabled={complianceGenerating}

View File

@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
base: '/app/',
base: '/',
plugins: [react()],
resolve: {
alias: {

View File

@@ -5,18 +5,9 @@ server {
client_max_body_size 50M;
absolute_redirect off;
# New React app at /app/
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 /
# React SPA at root
location / {
root /usr/share/nginx/html;
index index.html;
root /usr/share/nginx/html/app;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}